Compare commits
200 Commits
version/20
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
2b730dec54 | |||
2aacb311bc | |||
40055ef01b | |||
75608dce5c | |||
b0f7083879 | |||
62bf79ce32 | |||
7a16c9cb14 | |||
d29d161ac6 | |||
653631ac77 | |||
cde303e780 | |||
aa359a032c | |||
6491065aab | |||
79eec5a3a0 | |||
cd5e091937 | |||
7ed8952803 | |||
c1f302fb7c | |||
cb31e52d0e | |||
782764ac73 | |||
d0c56325ef | |||
73d57d6f82 | |||
2716a26887 | |||
0452537e8b | |||
d1a1bfbbc5 | |||
a69fcbca9a | |||
1ac4dacc3b | |||
bcf7e162a4 | |||
f44956bd61 | |||
cb37e5c10e | |||
73bb778d62 | |||
b612a82e16 | |||
83991c743e | |||
09f43ca43b | |||
1c91835a26 | |||
c032914092 | |||
3634bf4629 | |||
45f99fbaf0 | |||
e31a3307b5 | |||
d28fcca344 | |||
c296e1214c | |||
d676cf6e3f | |||
39d87841d0 | |||
fcd879034c | |||
b285814e24 | |||
1a6ea72c09 | |||
c251b87f8c | |||
21a9aa229a | |||
5f6565ee27 | |||
afad55a357 | |||
f25d76fa43 | |||
10b45d954e | |||
339eaf37f2 | |||
f723fdd551 | |||
8359f0bfb3 | |||
ee610a906a | |||
828eeb5ebb | |||
c9c177d8f9 | |||
c19afa4f16 | |||
941bc61b31 | |||
282b364606 | |||
ad4bc4083d | |||
ebe282eb1a | |||
830c26ca25 | |||
ed3b4a3d4a | |||
975c4ddc04 | |||
7e2896298a | |||
cba9cf8361 | |||
bf12580f64 | |||
75ef4ce596 | |||
c2f3ce11b0 | |||
3c256fecc6 | |||
0285b84133 | |||
99a371a02c | |||
c7e6eb8896 | |||
674bd9e05c | |||
b79901df87 | |||
b248f450dd | |||
05db9e5c40 | |||
234a5e2b66 | |||
aea1736f70 | |||
9f4a4449f5 | |||
b6b55e2336 | |||
8f2805e05b | |||
4f3583cd7e | |||
617e90dca3 | |||
f7408626a8 | |||
4dcb15af46 | |||
89beb7a9f7 | |||
28eeb4798e | |||
79b92e764e | |||
919336a519 | |||
27e04589c1 | |||
ba44fbdac8 | |||
0e093a8917 | |||
d0bfb99859 | |||
93bdea3769 | |||
e681654af7 | |||
cab7593dca | |||
cf92f9aefc | |||
8d72b3498d | |||
42ab858c50 | |||
a1abae9ab1 | |||
8f36b49061 | |||
64b4e851ce | |||
40a62ac1e5 | |||
5df60e4d87 | |||
50ebc8522d | |||
eddca478dc | |||
99a7fca08e | |||
a7e3602908 | |||
74169860cf | |||
52bb774f73 | |||
f26fcaf825 | |||
b8e92e2f11 | |||
08adfc94d6 | |||
236fafb735 | |||
5ad9ddee3c | |||
24d220ff49 | |||
3364c195b7 | |||
50aa87d141 | |||
72b375023d | |||
77ba186818 | |||
2fe6de0505 | |||
bf9e969b53 | |||
184f119b16 | |||
ebc06f1abe | |||
0f8880ab0a | |||
ee56da5092 | |||
2152004502 | |||
45d0b80d02 | |||
96065eb942 | |||
ac944fee8b | |||
1d0e5fc353 | |||
1f97420207 | |||
ae07f13a87 | |||
0aec504170 | |||
3b4c9bcc57 | |||
5182a6741e | |||
da7635ae5c | |||
a92a0fb60a | |||
cb10c1753b | |||
ae654bd4c8 | |||
28192655ec | |||
9582294eb8 | |||
0172430d7d | |||
1454b65933 | |||
432a7792e2 | |||
54069618b4 | |||
81feb313df | |||
e6b275add3 | |||
27016a5527 | |||
4c29d517f0 | |||
180d27cc37 | |||
5a8b356dc7 | |||
3195640776 | |||
f463296d47 | |||
adf4b23c01 | |||
d900a2b6a9 | |||
95a2fddfa8 | |||
8f7d21b692 | |||
3f84abec2f | |||
b5c857aff4 | |||
f8dee09107 | |||
84a800583c | |||
88de94f014 | |||
25549ec339 | |||
fe4923bff6 | |||
bb1a0b6bd2 | |||
879b5ead71 | |||
1670ec9167 | |||
ac52667327 | |||
0d7c5c2108 | |||
73e3d19384 | |||
f6e0f0282d | |||
3f42067a8f | |||
ed6f5b98df | |||
dd290e264c | |||
c85484fc00 | |||
663dffd8be | |||
c15d0c3d17 | |||
bf09a54f35 | |||
930dd51663 | |||
12a523c7aa | |||
ea9a6d57dd | |||
91958e1232 | |||
8925afb089 | |||
ccafe7be4f | |||
8279690a8f | |||
763d3ae76a | |||
b775e7f4d3 | |||
3d8d93ece5 | |||
06af306e8a | |||
9257f3c919 | |||
2fe7f4cf04 | |||
04399bc8bb | |||
fcbcfbc3c0 | |||
3e4ce62dfe | |||
d8292151e6 | |||
3d01a59b34 | |||
5df15c4105 | |||
75d695105d |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2021.9.1-rc2
|
||||
current_version = 2021.9.6
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
||||
|
1
.github/codespell-words.txt
vendored
Normal file
1
.github/codespell-words.txt
vendored
Normal file
@ -0,0 +1 @@
|
||||
keypair
|
2
.github/workflows/ci-web.yml
vendored
2
.github/workflows/ci-web.yml
vendored
@ -61,7 +61,7 @@ jobs:
|
||||
npm install
|
||||
- name: Generate API
|
||||
run: make gen-web
|
||||
- name: prettier
|
||||
- name: lit-analyse
|
||||
run: |
|
||||
cd web
|
||||
npm run lit-analyse
|
||||
|
20
.github/workflows/release-publish.yml
vendored
20
.github/workflows/release-publish.yml
vendored
@ -33,14 +33,14 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik:2021.9.1-rc2,
|
||||
beryju/authentik:2021.9.6,
|
||||
beryju/authentik:latest,
|
||||
ghcr.io/goauthentik/server:2021.9.1-rc2,
|
||||
ghcr.io/goauthentik/server:2021.9.6,
|
||||
ghcr.io/goauthentik/server:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- name: Building Docker Image (stable)
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.9.1-rc2', 'rc') }}
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.9.6', 'rc') }}
|
||||
run: |
|
||||
docker pull beryju/authentik:latest
|
||||
docker tag beryju/authentik:latest beryju/authentik:stable
|
||||
@ -75,14 +75,14 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik-proxy:2021.9.1-rc2,
|
||||
beryju/authentik-proxy:2021.9.6,
|
||||
beryju/authentik-proxy:latest,
|
||||
ghcr.io/goauthentik/proxy:2021.9.1-rc2,
|
||||
ghcr.io/goauthentik/proxy:2021.9.6,
|
||||
ghcr.io/goauthentik/proxy:latest
|
||||
file: proxy.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: Building Docker Image (stable)
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.9.1-rc2', 'rc') }}
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.9.6', 'rc') }}
|
||||
run: |
|
||||
docker pull beryju/authentik-proxy:latest
|
||||
docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable
|
||||
@ -117,14 +117,14 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik-ldap:2021.9.1-rc2,
|
||||
beryju/authentik-ldap:2021.9.6,
|
||||
beryju/authentik-ldap:latest,
|
||||
ghcr.io/goauthentik/ldap:2021.9.1-rc2,
|
||||
ghcr.io/goauthentik/ldap:2021.9.6,
|
||||
ghcr.io/goauthentik/ldap:latest
|
||||
file: ldap.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: Building Docker Image (stable)
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.9.1-rc2', 'rc') }}
|
||||
if: ${{ github.event_name == 'release' && !contains('2021.9.6', 'rc') }}
|
||||
run: |
|
||||
docker pull beryju/authentik-ldap:latest
|
||||
docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable
|
||||
@ -175,7 +175,7 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
version: authentik@2021.9.1-rc2
|
||||
version: authentik@2021.9.6
|
||||
environment: beryjuorg-prod
|
||||
sourcemaps: './web/dist'
|
||||
url_prefix: '~/static/dist'
|
||||
|
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@ -27,7 +27,7 @@ jobs:
|
||||
docker-compose run -u root server test
|
||||
- name: Extract version number
|
||||
id: get_version
|
||||
uses: actions/github-script@v4.1
|
||||
uses: actions/github-script@v5
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
@ -117,7 +117,7 @@ This section guides you through submitting a bug report for authentik. Following
|
||||
|
||||
Whenever authentik encounters an error, it will be logged as an Event with the type `system_exception`. This event type has a button to directly open a pre-filled GitHub issue form.
|
||||
|
||||
This form will have the full stack trace of the error that ocurred and shouldn't contain any sensitive data.
|
||||
This form will have the full stack trace of the error that occurred and shouldn't contain any sensitive data.
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
|
14
Dockerfile
14
Dockerfile
@ -1,5 +1,5 @@
|
||||
# Stage 1: Lock python dependencies
|
||||
FROM python:3.9-slim-buster as locker
|
||||
FROM docker.io/python:3.9-slim-buster as locker
|
||||
|
||||
COPY ./Pipfile /app/
|
||||
COPY ./Pipfile.lock /app/
|
||||
@ -11,7 +11,7 @@ RUN pip install pipenv && \
|
||||
pipenv lock -r --dev-only > requirements-dev.txt
|
||||
|
||||
# Stage 2: Build website
|
||||
FROM node as website-builder
|
||||
FROM docker.io/node as website-builder
|
||||
|
||||
COPY ./website /static/
|
||||
|
||||
@ -19,7 +19,7 @@ ENV NODE_ENV=production
|
||||
RUN cd /static && npm i && npm run build-docs-only
|
||||
|
||||
# Stage 3: Build webui
|
||||
FROM node as web-builder
|
||||
FROM docker.io/node as web-builder
|
||||
|
||||
COPY ./web /static/
|
||||
|
||||
@ -27,7 +27,7 @@ ENV NODE_ENV=production
|
||||
RUN cd /static && npm i && npm run build
|
||||
|
||||
# Stage 4: Build go proxy
|
||||
FROM golang:1.17.1 AS builder
|
||||
FROM docker.io/golang:1.17.1 AS builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
@ -47,7 +47,7 @@ COPY ./go.sum /work/go.sum
|
||||
RUN go build -o /work/authentik ./cmd/server/main.go
|
||||
|
||||
# Stage 5: Run
|
||||
FROM python:3.9-slim-buster
|
||||
FROM docker.io/python:3.9-slim-buster
|
||||
|
||||
WORKDIR /
|
||||
COPY --from=locker /app/requirements.txt /
|
||||
@ -80,8 +80,12 @@ COPY ./lifecycle/ /lifecycle
|
||||
COPY --from=builder /work/authentik /authentik-proxy
|
||||
|
||||
USER authentik
|
||||
|
||||
ENV TMPDIR /dev/shm/
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV prometheus_multiproc_dir /dev/shm/
|
||||
ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak healthcheck" ]
|
||||
|
||||
ENTRYPOINT [ "/lifecycle/ak" ]
|
||||
|
1
Makefile
1
Makefile
@ -20,6 +20,7 @@ test:
|
||||
lint-fix:
|
||||
isort authentik tests lifecycle
|
||||
black authentik tests lifecycle
|
||||
codespell -I .github/codespell-words.txt -S 'web/src/locales/**' -w authentik internal cmd web/src website/src
|
||||
|
||||
lint:
|
||||
pyright authentik tests lifecycle
|
||||
|
1
Pipfile
1
Pipfile
@ -48,6 +48,7 @@ duo-client = "*"
|
||||
ua-parser = "*"
|
||||
deepmerge = "*"
|
||||
colorama = "*"
|
||||
codespell = "*"
|
||||
|
||||
[dev-packages]
|
||||
bandit = "*"
|
||||
|
323
Pipfile.lock
generated
323
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "19d5324fd1a4af125ed57a683030ca14ee2d3648117748e4b32656875484728e"
|
||||
"sha256": "babb6061c555f8f239f00210b2a0356763bdaaca2f3d704cf3444891b84db84d"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {},
|
||||
@ -120,27 +120,27 @@
|
||||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:63b9846c26e0905f4e9e39d6b59f152330c53a926d693439161c43dcf9779365",
|
||||
"sha256:a9232185d8e7e2fd2b166c0ebee5d7b1f787fdb3093f33bbf5aa932c08f0ccac"
|
||||
"sha256:7b45b224442c479de4bc6e6e9cb0557b642fc7a77edc8702e393ccaa2e0aa128",
|
||||
"sha256:c388da7dc1a596755f39de990a72e05cee558d098e81de63de55bd9598cc5134"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.18.42"
|
||||
"version": "==1.18.48"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:0952d1200968365b440045efe8e45bbae38cf603fee12bcfc3d7b5f963cbfa18",
|
||||
"sha256:6de4fec4ee10987e4dea96f289553c2f45109fcaafcb74a5baee1221926e1306"
|
||||
"sha256:17a10dd33334e7e3aaa4e12f66317284f96bb53267e20bc877a187c442681772",
|
||||
"sha256:2089f9fa36a59d8c02435c49d58ccc7b3ceb9c0c054ea4f71631c3c3a1c5245e"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==1.21.42"
|
||||
"version": "==1.21.51"
|
||||
},
|
||||
"cachetools": {
|
||||
"hashes": [
|
||||
"sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001",
|
||||
"sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff"
|
||||
"sha256:0a3d3556c2c3befdbba2f93b78792c199c66201c999e97947ea0b7437758246b",
|
||||
"sha256:6a6fa6802188ab7e77bab2db001d676e854499552b0037d999d5b9f211db5250"
|
||||
],
|
||||
"markers": "python_version ~= '3.5'",
|
||||
"version": "==4.2.2"
|
||||
"version": "==4.2.3"
|
||||
},
|
||||
"cbor2": {
|
||||
"hashes": [
|
||||
@ -252,11 +252,11 @@
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:7098e7e862f6370a2a8d1a6398cd359815c45d12626267652c3f13dec58e2367",
|
||||
"sha256:fa471a601dfea0f492e4f4fca035cd82155e65dc45c9b83bf4322dfab63755dd"
|
||||
"sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6",
|
||||
"sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"
|
||||
],
|
||||
"markers": "python_version >= '3'",
|
||||
"version": "==2.0.5"
|
||||
"version": "==2.0.6"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
@ -268,9 +268,11 @@
|
||||
},
|
||||
"click-didyoumean": {
|
||||
"hashes": [
|
||||
"sha256:112229485c9704ff51362fe34b2d4f0b12fc71cc20f6d2b3afabed4b8bfa6aeb"
|
||||
"sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667",
|
||||
"sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035"
|
||||
],
|
||||
"version": "==0.0.3"
|
||||
"markers": "python_version < '4' and python_full_version >= '3.6.2'",
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"click-plugins": {
|
||||
"hashes": [
|
||||
@ -286,6 +288,14 @@
|
||||
],
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"codespell": {
|
||||
"hashes": [
|
||||
"sha256:19d3fe5644fef3425777e66f225a8c82d39059dcfe9edb3349a8a2cf48383ee5",
|
||||
"sha256:b864c7d917316316ac24272ee992d7937c3519be4569209c5b60035ac5d569b5"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.1.0"
|
||||
},
|
||||
"colorama": {
|
||||
"hashes": [
|
||||
"sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
|
||||
@ -303,25 +313,28 @@
|
||||
},
|
||||
"cryptography": {
|
||||
"hashes": [
|
||||
"sha256:0a7dcbcd3f1913f664aca35d47c1331fce738d44ec34b7be8b9d332151b0b01e",
|
||||
"sha256:1eb7bb0df6f6f583dd8e054689def236255161ebbcf62b226454ab9ec663746b",
|
||||
"sha256:21ca464b3a4b8d8e86ba0ee5045e103a1fcfac3b39319727bc0fc58c09c6aff7",
|
||||
"sha256:34dae04a0dce5730d8eb7894eab617d8a70d0c97da76b905de9efb7128ad7085",
|
||||
"sha256:3520667fda779eb788ea00080124875be18f2d8f0848ec00733c0ec3bb8219fc",
|
||||
"sha256:3fa3a7ccf96e826affdf1a0a9432be74dc73423125c8f96a909e3835a5ef194a",
|
||||
"sha256:5b0fbfae7ff7febdb74b574055c7466da334a5371f253732d7e2e7525d570498",
|
||||
"sha256:8695456444f277af73a4877db9fc979849cd3ee74c198d04fc0776ebc3db52b9",
|
||||
"sha256:94cc5ed4ceaefcbe5bf38c8fba6a21fc1d365bb8fb826ea1688e3370b2e24a1c",
|
||||
"sha256:94fff993ee9bc1b2440d3b7243d488c6a3d9724cc2b09cdb297f6a886d040ef7",
|
||||
"sha256:9965c46c674ba8cc572bc09a03f4c649292ee73e1b683adb1ce81e82e9a6a0fb",
|
||||
"sha256:a00cf305f07b26c351d8d4e1af84ad7501eca8a342dedf24a7acb0e7b7406e14",
|
||||
"sha256:a305600e7a6b7b855cd798e00278161b681ad6e9b7eca94c721d5f588ab212af",
|
||||
"sha256:cd65b60cfe004790c795cc35f272e41a3df4631e2fb6b35aa7ac6ef2859d554e",
|
||||
"sha256:d2a6e5ef66503da51d2110edf6c403dc6b494cc0082f85db12f54e9c5d4c3ec5",
|
||||
"sha256:d9ec0e67a14f9d1d48dd87a2531009a9b251c02ea42851c060b25c782516ff06",
|
||||
"sha256:f44d141b8c4ea5eb4dbc9b3ad992d45580c1d22bf5e24363f2fbf50c2d7ae8a7"
|
||||
"sha256:07bb7fbfb5de0980590ddfc7f13081520def06dc9ed214000ad4372fb4e3c7f6",
|
||||
"sha256:18d90f4711bf63e2fb21e8c8e51ed8189438e6b35a6d996201ebd98a26abbbe6",
|
||||
"sha256:1ed82abf16df40a60942a8c211251ae72858b25b7421ce2497c2eb7a1cee817c",
|
||||
"sha256:22a38e96118a4ce3b97509443feace1d1011d0571fae81fc3ad35f25ba3ea999",
|
||||
"sha256:2d69645f535f4b2c722cfb07a8eab916265545b3475fdb34e0be2f4ee8b0b15e",
|
||||
"sha256:4a2d0e0acc20ede0f06ef7aa58546eee96d2592c00f450c9acb89c5879b61992",
|
||||
"sha256:54b2605e5475944e2213258e0ab8696f4f357a31371e538ef21e8d61c843c28d",
|
||||
"sha256:7075b304cd567694dc692ffc9747f3e9cb393cc4aa4fb7b9f3abd6f5c4e43588",
|
||||
"sha256:7b7ceeff114c31f285528ba8b390d3e9cfa2da17b56f11d366769a807f17cbaa",
|
||||
"sha256:7eba2cebca600a7806b893cb1d541a6e910afa87e97acf2021a22b32da1df52d",
|
||||
"sha256:928185a6d1ccdb816e883f56ebe92e975a262d31cc536429041921f8cb5a62fd",
|
||||
"sha256:9933f28f70d0517686bd7de36166dda42094eac49415459d9bdf5e7df3e0086d",
|
||||
"sha256:a688ebcd08250eab5bb5bca318cc05a8c66de5e4171a65ca51db6bd753ff8953",
|
||||
"sha256:abb5a361d2585bb95012a19ed9b2c8f412c5d723a9836418fab7aaa0243e67d2",
|
||||
"sha256:c10c797ac89c746e488d2ee92bd4abd593615694ee17b2500578b63cad6b93a8",
|
||||
"sha256:ced40344e811d6abba00295ced98c01aecf0c2de39481792d87af4fa58b7b4d6",
|
||||
"sha256:d57e0cdc1b44b6cdf8af1d01807db06886f10177469312fbde8f44ccbb284bc9",
|
||||
"sha256:d99915d6ab265c22873f1b4d6ea5ef462ef797b4140be4c9d8b179915e0985c6",
|
||||
"sha256:eb80e8a1f91e4b7ef8b33041591e6d89b2b8e122d787e87eeb2b08da71bb16ad",
|
||||
"sha256:ebeddd119f526bcf323a89f853afb12e225902a24d29b55fe18dd6fcb2838a76"
|
||||
],
|
||||
"version": "==3.4.8"
|
||||
"version": "==35.0.0"
|
||||
},
|
||||
"dacite": {
|
||||
"hashes": [
|
||||
@ -369,11 +382,11 @@
|
||||
},
|
||||
"django-filter": {
|
||||
"hashes": [
|
||||
"sha256:84e9d5bb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06",
|
||||
"sha256:e00d32cebdb3d54273c48f4f878f898dced8d5dfaad009438fe61ebdf535ace1"
|
||||
"sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e",
|
||||
"sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.4.0"
|
||||
"version": "==21.1"
|
||||
},
|
||||
"django-guardian": {
|
||||
"hashes": [
|
||||
@ -449,11 +462,11 @@
|
||||
},
|
||||
"drf-spectacular": {
|
||||
"hashes": [
|
||||
"sha256:47ef6ec8ff48ac8aede6ec12450a55fee381cf84de969ef1724dcde5a93de6b8",
|
||||
"sha256:d746b936cb4cddec380ea95bf91de6a6721777dfc42e0eea53b83c61a625e94e"
|
||||
"sha256:65df818226477cdfa629947ea52bc0cc13eb40550b192eeccec64a6b782651fd",
|
||||
"sha256:f71205da3645d770545abeaf48e8a15afd6ee9a76e57c03df4592e51be1059bf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.18.2"
|
||||
"version": "==0.19.0"
|
||||
},
|
||||
"duo-client": {
|
||||
"hashes": [
|
||||
@ -480,19 +493,19 @@
|
||||
},
|
||||
"geoip2": {
|
||||
"hashes": [
|
||||
"sha256:906a1dbf15a179a1af3522970e8420ab15bb3e0afc526942cc179e12146d9c1d",
|
||||
"sha256:b97b44031fdc463e84eb1316b4f19edd978cb1d78703465fcb1e36dc5a822ba6"
|
||||
"sha256:f150bed3190d543712a17467208388d31bd8ddb49b2226fba53db8aaedb8ba89",
|
||||
"sha256:f9172cdfb2a5f9225ace5e30dd7426413ad28798a5f474cd1538780686bd6a87"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.2.0"
|
||||
"version": "==4.4.0"
|
||||
},
|
||||
"google-auth": {
|
||||
"hashes": [
|
||||
"sha256:7ae5eda089d393ca01658b550df24913cbbbdd34e9e6dedc1cea747485ae0c04",
|
||||
"sha256:bde03220ed56e4e147dec92339c90ce95159dce657e2cccd0ac1fe82f6a96284"
|
||||
"sha256:2a92b485afed5292946b324e91fcbe03db277ee4cb64c998c6cfa66d4af01dee",
|
||||
"sha256:6dc8173abd50f25b6e62fc5b42802c96fc7cd9deb9bfeeb10a79f5606225cdf4"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.1.0"
|
||||
"version": "==2.2.1"
|
||||
},
|
||||
"gunicorn": {
|
||||
"hashes": [
|
||||
@ -616,10 +629,10 @@
|
||||
},
|
||||
"jsonschema": {
|
||||
"hashes": [
|
||||
"sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163",
|
||||
"sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"
|
||||
"sha256:bc51325b929171791c42ebc1c70b9713eb134d3bb8ebd5474c8b659b15be6d86",
|
||||
"sha256:c773028c649441ab980015b5b622f4cd5134cf563daaf0235ca4b73cc3734f20"
|
||||
],
|
||||
"version": "==3.2.0"
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"kombu": {
|
||||
"hashes": [
|
||||
@ -704,10 +717,10 @@
|
||||
},
|
||||
"maxminddb": {
|
||||
"hashes": [
|
||||
"sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc"
|
||||
"sha256:e37707ec4fab115804670e0fb7aedb4b57075a8b6f80052bdc648d3c005184e5"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.0.3"
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"msgpack": {
|
||||
"hashes": [
|
||||
@ -898,39 +911,39 @@
|
||||
},
|
||||
"pycryptodome": {
|
||||
"hashes": [
|
||||
"sha256:09c1555a3fa450e7eaca41ea11cd00afe7c91fef52353488e65663777d8524e0",
|
||||
"sha256:12222a5edc9ca4a29de15fbd5339099c4c26c56e13c2ceddf0b920794f26165d",
|
||||
"sha256:1723ebee5561628ce96748501cdaa7afaa67329d753933296321f0be55358dce",
|
||||
"sha256:1c5e1ca507de2ad93474be5cfe2bfa76b7cf039a1a32fc196f40935944871a06",
|
||||
"sha256:2603c98ae04aac675fefcf71a6c87dc4bb74a75e9071ae3923bbc91a59f08d35",
|
||||
"sha256:2dea65df54349cdfa43d6b2e8edb83f5f8d6861e5cf7b1fbc3e34c5694c85e27",
|
||||
"sha256:31c1df17b3dc5f39600a4057d7db53ac372f492c955b9b75dd439f5d8b460129",
|
||||
"sha256:38661348ecb71476037f1e1f553159b80d256c00f6c0b00502acac891f7116d9",
|
||||
"sha256:3e2e3a06580c5f190df843cdb90ea28d61099cf4924334d5297a995de68e4673",
|
||||
"sha256:3f840c49d38986f6e17dbc0673d37947c88bc9d2d9dba1c01b979b36f8447db1",
|
||||
"sha256:501ab36aae360e31d0ec370cf5ce8ace6cb4112060d099b993bc02b36ac83fb6",
|
||||
"sha256:60386d1d4cfaad299803b45a5bc2089696eaf6cdd56f9fc17479a6f89595cfc8",
|
||||
"sha256:6260e24d41149268122dd39d4ebd5941e9d107f49463f7e071fd397e29923b0c",
|
||||
"sha256:6bbf7fee7b7948b29d7e71fcacf48bac0c57fb41332007061a933f2d996f9713",
|
||||
"sha256:6d2df5223b12437e644ce0a3be7809471ffa71de44ccd28b02180401982594a6",
|
||||
"sha256:758949ca62690b1540dfb24ad773c6da9cd0e425189e83e39c038bbd52b8e438",
|
||||
"sha256:77997519d8eb8a4adcd9a47b9cec18f9b323e296986528186c0e9a7a15d6a07e",
|
||||
"sha256:7fd519b89585abf57bf47d90166903ec7b43af4fe23c92273ea09e6336af5c07",
|
||||
"sha256:98213ac2b18dc1969a47bc65a79a8fca02a414249d0c8635abb081c7f38c91b6",
|
||||
"sha256:99b2f3fc51d308286071d0953f92055504a6ffe829a832a9fc7a04318a7683dd",
|
||||
"sha256:9b6f711b25e01931f1c61ce0115245a23cdc8b80bf8539ac0363bdcf27d649b6",
|
||||
"sha256:a3105a0eb63eacf98c2ecb0eb4aa03f77f40fbac2bdde22020bb8a536b226bb8",
|
||||
"sha256:a8eb8b6ea09ec1c2535bf39914377bc8abcab2c7d30fa9225eb4fe412024e427",
|
||||
"sha256:a92d5c414e8ee1249e850789052608f582416e82422502dc0ac8c577808a9067",
|
||||
"sha256:d3d6958d53ad307df5e8469cc44474a75393a434addf20ecd451f38a72fe29b8",
|
||||
"sha256:e0a4d5933a88a2c98bbe19c0c722f5483dc628d7a38338ac2cb64a7dbd34064b",
|
||||
"sha256:e3bf558c6aeb49afa9f0c06cee7fb5947ee5a1ff3bd794b653d39926b49077fa",
|
||||
"sha256:e61e363d9a5d7916f3a4ce984a929514c0df3daf3b1b2eb5e6edbb131ee771cf",
|
||||
"sha256:f977cdf725b20f6b8229b0c87acb98c7717e742ef9f46b113985303ae12a99da",
|
||||
"sha256:fc7489a50323a0df02378bc2fff86eb69d94cc5639914346c736be981c6a02e7"
|
||||
"sha256:04e14c732c3693d2830839feed5129286ce47ffa8bfe90e4ae042c773e51c677",
|
||||
"sha256:11d3164fb49fdee000fde05baecce103c0c698168ef1a18d9c7429dd66f0f5bb",
|
||||
"sha256:217dcc0c92503f7dd4b3d3b7d974331a4419f97f555c99a845c3b366fed7056b",
|
||||
"sha256:24c1b7705d19d8ae3e7255431efd2e526006855df62620118dd7b5374c6372f6",
|
||||
"sha256:309529d2526f3fb47102aeef376b3459110a6af7efb162e860b32e3a17a46f06",
|
||||
"sha256:3a153658d97258ca20bf18f7fe31c09cc7c558b6f8974a6ec74e19f6c634bd64",
|
||||
"sha256:3f9fb499e267039262569d08658132c9cd8b136bf1d8c56b72f70ed05551e526",
|
||||
"sha256:3faa6ebd35c61718f3f8862569c1f38450c24f3ededb213e1a64806f02f584bc",
|
||||
"sha256:40083b0d7f277452c7f2dd4841801f058cc12a74c219ee4110d65774c6a58bef",
|
||||
"sha256:49e54f2245befb0193848c8c8031d8d1358ed4af5a1ae8d0a3ba669a5cdd3a72",
|
||||
"sha256:4e8fc4c48365ce8a542fe48bf1360da05bb2851df12f64fc94d751705e7cdbe7",
|
||||
"sha256:54d4e4d45f349d8c4e2f31c2734637ff62a844af391b833f789da88e43a8f338",
|
||||
"sha256:66301e4c42dee43ee2da256625d3fe81ef98cc9924c2bd535008cc3ad8ded77b",
|
||||
"sha256:6b45fcace5a5d9c57ba87cf804b161adc62aa826295ce7f7acbcbdc0df74ed37",
|
||||
"sha256:7efec2418e9746ec48e264eea431f8e422d931f71c57b1c96ee202b117f58fa9",
|
||||
"sha256:851e6d4930b160417235955322db44adbdb19589918670d63f4acd5d92959ac0",
|
||||
"sha256:8e82524e7c354033508891405574d12e612cc4fdd3b55d2c238fc1a3e300b606",
|
||||
"sha256:8ec154ec445412df31acf0096e7f715e30e167c8f2318b8f5b1ab7c28f4c82f7",
|
||||
"sha256:91ba4215a1f37d0f371fe43bc88c5ff49c274849f3868321c889313787de7672",
|
||||
"sha256:97e7df67a4da2e3f60612bbfd6c3f243a63a15d8f4797dd275e1d7b44a65cb12",
|
||||
"sha256:9a2312440057bf29b9582f72f14d79692044e63bfbc4b4bbea8559355f44f3dd",
|
||||
"sha256:a7471646d8cd1a58bb696d667dcb3853e5c9b341b68dcf3c3cc0893d0f98ca5f",
|
||||
"sha256:ac3012c36633564b2b5539bb7c6d9175f31d2ce74844e9abe654c428f02d0fd8",
|
||||
"sha256:b1daf251395af7336ddde6a0015ba5e632c18fe646ba930ef87402537358e3b4",
|
||||
"sha256:b217b4525e60e1af552d62bec01b4685095436d4de5ecde0f05d75b2f95ba6d4",
|
||||
"sha256:c61ea053bd5d4c12a063d7e704fbe1c45abb5d2510dab55bd95d166ba661604f",
|
||||
"sha256:c6469d1453f5864e3321a172b0aa671b938d753cbf2376b99fa2ab8841539bb8",
|
||||
"sha256:cefe6b267b8e5c3c72e11adec35a9c7285b62e8ea141b63e87055e9a9e5f2f8c",
|
||||
"sha256:d713dc0910e5ded07852a05e9b75f1dd9d3a31895eebee0668f612779b2a748c",
|
||||
"sha256:db15fa07d2a4c00beeb5e9acdfdbc1c79f9ccfbdc1a8f36c82c4aa44951b33c9"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.10.1"
|
||||
"version": "==3.10.4"
|
||||
},
|
||||
"pyjwt": {
|
||||
"hashes": [
|
||||
@ -942,10 +955,10 @@
|
||||
},
|
||||
"pyopenssl": {
|
||||
"hashes": [
|
||||
"sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51",
|
||||
"sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b"
|
||||
"sha256:5e2d8c5e46d0d865ae933bef5230090bdaf5506281e9eec60fa250ee80600cb3",
|
||||
"sha256:8935bd4920ab9abfebb07c41a4f58296407ed77f04bd1a92914044b848ba1ed6"
|
||||
],
|
||||
"version": "==20.0.1"
|
||||
"version": "==21.0.0"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
@ -1082,11 +1095,11 @@
|
||||
},
|
||||
"sentry-sdk": {
|
||||
"hashes": [
|
||||
"sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c",
|
||||
"sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52"
|
||||
"sha256:b9844751e40710e84a457c5bc29b21c383ccb2b63d76eeaad72f7f1c808c8828",
|
||||
"sha256:c091cc7115ff25fe3a0e410dbecd7a996f81a3f6137d2272daef32d6c3cfa6dc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.1"
|
||||
"version": "==1.4.3"
|
||||
},
|
||||
"service-identity": {
|
||||
"hashes": [
|
||||
@ -1176,11 +1189,11 @@
|
||||
"secure"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4",
|
||||
"sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"
|
||||
"sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece",
|
||||
"sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.26.6"
|
||||
"version": "==1.26.7"
|
||||
},
|
||||
"uvicorn": {
|
||||
"extras": [
|
||||
@ -1410,11 +1423,11 @@
|
||||
},
|
||||
"astroid": {
|
||||
"hashes": [
|
||||
"sha256:3b680ce0419b8a771aba6190139a3998d14b413852506d99aff8dc2bf65ee67c",
|
||||
"sha256:dc1e8b28427d6bbef6b8842b18765ab58f558c42bb80540bd7648c98412af25e"
|
||||
"sha256:dcc06f6165f415220013801642bd6c9808a02967070919c4b746c6864c205471",
|
||||
"sha256:fe81f80c0b35264acb5653302ffbd935d394f1775c5e4487df745bf9c2442708"
|
||||
],
|
||||
"markers": "python_version ~= '3.6'",
|
||||
"version": "==2.7.3"
|
||||
"version": "==2.8.0"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
@ -1457,11 +1470,11 @@
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:7098e7e862f6370a2a8d1a6398cd359815c45d12626267652c3f13dec58e2367",
|
||||
"sha256:fa471a601dfea0f492e4f4fca035cd82155e65dc45c9b83bf4322dfab63755dd"
|
||||
"sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6",
|
||||
"sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"
|
||||
],
|
||||
"markers": "python_version >= '3'",
|
||||
"version": "==2.0.5"
|
||||
"version": "==2.0.6"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
@ -1547,11 +1560,11 @@
|
||||
},
|
||||
"gitpython": {
|
||||
"hashes": [
|
||||
"sha256:b838a895977b45ab6f0cc926a9045c8d1c44e2b653c1fcc39fe91f42c6e8f05b",
|
||||
"sha256:fce760879cd2aebd2991b3542876dc5c4a909b30c9d69dfc488e504a8db37ee8"
|
||||
"sha256:dc0a7f2f697657acc8d7f89033e8b1ea94dd90356b2983bca89dc8d2ab3cc647",
|
||||
"sha256:df83fdf5e684fef7c6ee2c02fc68a5ceb7e7e759d08b694088d0cacb4eba59e5"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.1.18"
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.1.24"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
@ -1642,11 +1655,11 @@
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f",
|
||||
"sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648"
|
||||
"sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2",
|
||||
"sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.3.0"
|
||||
"version": "==2.4.0"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
@ -1666,11 +1679,11 @@
|
||||
},
|
||||
"pylint": {
|
||||
"hashes": [
|
||||
"sha256:6758cce3ddbab60c52b57dcc07f0c5d779e5daf0cf50f6faacbef1d3ea62d2a1",
|
||||
"sha256:e178e96b6ba171f8ef51fbce9ca30931e6acbea4a155074d80cc081596c9e852"
|
||||
"sha256:0f358e221c45cbd4dad2a1e4b883e75d28acdcccd29d40c76eb72b307269b126",
|
||||
"sha256:2c9843fff1a88ca0ad98a256806c82c5a8f86086e7ccbdb93297d86c3f90c436"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.10.2"
|
||||
"version": "==2.11.1"
|
||||
},
|
||||
"pylint-django": {
|
||||
"hashes": [
|
||||
@ -1748,49 +1761,49 @@
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
"sha256:04f6b9749e335bb0d2f68c707f23bb1773c3fb6ecd10edf0f04df12a8920d468",
|
||||
"sha256:08d74bfaa4c7731b8dac0a992c63673a2782758f7cfad34cf9c1b9184f911354",
|
||||
"sha256:0fc1f8f06977c2d4f5e3d3f0d4a08089be783973fc6b6e278bde01f0544ff308",
|
||||
"sha256:121f4b3185feaade3f85f70294aef3f777199e9b5c0c0245c774ae884b110a2d",
|
||||
"sha256:1413b5022ed6ac0d504ba425ef02549a57d0f4276de58e3ab7e82437892704fc",
|
||||
"sha256:1743345e30917e8c574f273f51679c294effba6ad372db1967852f12c76759d8",
|
||||
"sha256:28fc475f560d8f67cc8767b94db4c9440210f6958495aeae70fac8faec631797",
|
||||
"sha256:31a99a4796bf5aefc8351e98507b09e1b09115574f7c9dbb9cf2111f7220d2e2",
|
||||
"sha256:328a1fad67445550b982caa2a2a850da5989fd6595e858f02d04636e7f8b0b13",
|
||||
"sha256:473858730ef6d6ff7f7d5f19452184cd0caa062a20047f6d6f3e135a4648865d",
|
||||
"sha256:4cde065ab33bcaab774d84096fae266d9301d1a2f5519d7bd58fc55274afbf7a",
|
||||
"sha256:5f6a808044faae658f546dd5f525e921de9fa409de7a5570865467f03a626fc0",
|
||||
"sha256:610b690b406653c84b7cb6091facb3033500ee81089867ee7d59e675f9ca2b73",
|
||||
"sha256:66256b6391c057305e5ae9209941ef63c33a476b73772ca967d4a2df70520ec1",
|
||||
"sha256:6eebf512aa90751d5ef6a7c2ac9d60113f32e86e5687326a50d7686e309f66ed",
|
||||
"sha256:79aef6b5cd41feff359acaf98e040844613ff5298d0d19c455b3d9ae0bc8c35a",
|
||||
"sha256:808ee5834e06f57978da3e003ad9d6292de69d2bf6263662a1a8ae30788e080b",
|
||||
"sha256:8e44769068d33e0ea6ccdf4b84d80c5afffe5207aa4d1881a629cf0ef3ec398f",
|
||||
"sha256:999ad08220467b6ad4bd3dd34e65329dd5d0df9b31e47106105e407954965256",
|
||||
"sha256:9b006628fe43aa69259ec04ca258d88ed19b64791693df59c422b607b6ece8bb",
|
||||
"sha256:9d05ad5367c90814099000442b2125535e9d77581855b9bee8780f1b41f2b1a2",
|
||||
"sha256:a577a21de2ef8059b58f79ff76a4da81c45a75fe0bfb09bc8b7bb4293fa18983",
|
||||
"sha256:a617593aeacc7a691cc4af4a4410031654f2909053bd8c8e7db837f179a630eb",
|
||||
"sha256:abb48494d88e8a82601af905143e0de838c776c1241d92021e9256d5515b3645",
|
||||
"sha256:ac88856a8cbccfc14f1b2d0b829af354cc1743cb375e7f04251ae73b2af6adf8",
|
||||
"sha256:b4c220a1fe0d2c622493b0a1fd48f8f991998fb447d3cd368033a4b86cf1127a",
|
||||
"sha256:b844fb09bd9936ed158ff9df0ab601e2045b316b17aa8b931857365ea8586906",
|
||||
"sha256:bdc178caebd0f338d57ae445ef8e9b737ddf8fbc3ea187603f65aec5b041248f",
|
||||
"sha256:c206587c83e795d417ed3adc8453a791f6d36b67c81416676cad053b4104152c",
|
||||
"sha256:c61dcc1cf9fd165127a2853e2c31eb4fb961a4f26b394ac9fe5669c7a6592892",
|
||||
"sha256:c7cb4c512d2d3b0870e00fbbac2f291d4b4bf2634d59a31176a87afe2777c6f0",
|
||||
"sha256:d4a332404baa6665b54e5d283b4262f41f2103c255897084ec8f5487ce7b9e8e",
|
||||
"sha256:d5111d4c843d80202e62b4fdbb4920db1dcee4f9366d6b03294f45ed7b18b42e",
|
||||
"sha256:e1e8406b895aba6caa63d9fd1b6b1700d7e4825f78ccb1e5260551d168db38ed",
|
||||
"sha256:e8690ed94481f219a7a967c118abaf71ccc440f69acd583cab721b90eeedb77c",
|
||||
"sha256:ed283ab3a01d8b53de3a05bfdf4473ae24e43caee7dcb5584e86f3f3e5ab4374",
|
||||
"sha256:ed4b50355b066796dacdd1cf538f2ce57275d001838f9b132fab80b75e8c84dd",
|
||||
"sha256:ee329d0387b5b41a5dddbb6243a21cb7896587a651bebb957e2d2bb8b63c0791",
|
||||
"sha256:f3bf1bc02bc421047bfec3343729c4bbbea42605bcfd6d6bfe2c07ade8b12d2a",
|
||||
"sha256:f585cbbeecb35f35609edccb95efd95a3e35824cd7752b586503f7e6087303f1",
|
||||
"sha256:f60667673ff9c249709160529ab39667d1ae9fd38634e006bec95611f632e759"
|
||||
"sha256:0628ed7d6334e8f896f882a5c1240de8c4d9b0dd7c7fb8e9f4692f5684b7d656",
|
||||
"sha256:09eb62654030f39f3ba46bc6726bea464069c29d00a9709e28c9ee9623a8da4a",
|
||||
"sha256:0bba1f6df4eafe79db2ecf38835c2626dbd47911e0516f6962c806f83e7a99ae",
|
||||
"sha256:10a7a9cbe30bd90b7d9a1b4749ef20e13a3528e4215a2852be35784b6bd070f0",
|
||||
"sha256:17310b181902e0bb42b29c700e2c2346b8d81f26e900b1328f642e225c88bce1",
|
||||
"sha256:1e8d1898d4fb817120a5f684363b30108d7b0b46c7261264b100d14ec90a70e7",
|
||||
"sha256:2054dea683f1bda3a804fcfdb0c1c74821acb968093d0be16233873190d459e3",
|
||||
"sha256:29385c4dbb3f8b3a55ce13de6a97a3d21bd00de66acd7cdfc0b49cb2f08c906c",
|
||||
"sha256:295bc8a13554a25ad31e44c4bedabd3c3e28bba027e4feeb9bb157647a2344a7",
|
||||
"sha256:2cdb3789736f91d0b3333ac54d12a7e4f9efbc98f53cb905d3496259a893a8b3",
|
||||
"sha256:3baf3eaa41044d4ced2463fd5d23bf7bd4b03d68739c6c99a59ce1f95599a673",
|
||||
"sha256:4e61100200fa6ab7c99b61476f9f9653962ae71b931391d0264acfb4d9527d9c",
|
||||
"sha256:6266fde576e12357b25096351aac2b4b880b0066263e7bc7a9a1b4307991bb0e",
|
||||
"sha256:650c4f1fc4273f4e783e1d8e8b51a3e2311c2488ba0fcae6425b1e2c248a189d",
|
||||
"sha256:658e3477676009083422042c4bac2bdad77b696e932a3de001c42cc046f8eda2",
|
||||
"sha256:6adc1bd68f81968c9d249aab8c09cdc2cbe384bf2d2cb7f190f56875000cdc72",
|
||||
"sha256:6c4d83d21d23dd854ffbc8154cf293f4e43ba630aa9bd2539c899343d7f59da3",
|
||||
"sha256:6f74b6d8f59f3cfb8237e25c532b11f794b96f5c89a6f4a25857d85f84fbef11",
|
||||
"sha256:7783d89bd5413d183a38761fbc68279b984b9afcfbb39fa89d91f63763fbfb90",
|
||||
"sha256:7e3536f305f42ad6d31fc86636c54c7dafce8d634e56fef790fbacb59d499dd5",
|
||||
"sha256:821e10b73e0898544807a0692a276e539e5bafe0a055506a6882814b6a02c3ec",
|
||||
"sha256:835962f432bce92dc9bf22903d46c50003c8d11b1dc64084c8fae63bca98564a",
|
||||
"sha256:85c61bee5957e2d7be390392feac7e1d7abd3a49cbaed0c8cee1541b784c8561",
|
||||
"sha256:86f9931eb92e521809d4b64ec8514f18faa8e11e97d6c2d1afa1bcf6c20a8eab",
|
||||
"sha256:8a5c2250c0a74428fd5507ae8853706fdde0f23bfb62ee1ec9418eeacf216078",
|
||||
"sha256:8aec4b4da165c4a64ea80443c16e49e3b15df0f56c124ac5f2f8708a65a0eddc",
|
||||
"sha256:8c268e78d175798cd71d29114b0a1f1391c7d011995267d3b62319ec1a4ecaa1",
|
||||
"sha256:8d80087320632457aefc73f686f66139801959bf5b066b4419b92be85be3543c",
|
||||
"sha256:95e89a8558c8c48626dcffdf9c8abac26b7c251d352688e7ab9baf351e1c7da6",
|
||||
"sha256:9c371dd326289d85906c27ec2bc1dcdedd9d0be12b543d16e37bad35754bde48",
|
||||
"sha256:9c7cb25adba814d5f419733fe565f3289d6fa629ab9e0b78f6dff5fa94ab0456",
|
||||
"sha256:a731552729ee8ae9c546fb1c651c97bf5f759018fdd40d0e9b4d129e1e3a44c8",
|
||||
"sha256:aea4006b73b555fc5bdb650a8b92cf486d678afa168cf9b38402bb60bf0f9c18",
|
||||
"sha256:b0e3f59d3c772f2c3baaef2db425e6fc4149d35a052d874bb95ccfca10a1b9f4",
|
||||
"sha256:b15dc34273aefe522df25096d5d087abc626e388a28a28ac75a4404bb7668736",
|
||||
"sha256:c000635fd78400a558bd7a3c2981bb2a430005ebaa909d31e6e300719739a949",
|
||||
"sha256:c31f35a984caffb75f00a86852951a337540b44e4a22171354fb760cefa09346",
|
||||
"sha256:c50a6379763c733562b1fee877372234d271e5c78cd13ade5f25978aa06744db",
|
||||
"sha256:c94722bf403b8da744b7d0bb87e1f2529383003ceec92e754f768ef9323f69ad",
|
||||
"sha256:dcbbc9cfa147d55a577d285fd479b43103188855074552708df7acc31a476dd9",
|
||||
"sha256:fb9f5844db480e2ef9fce3a72e71122dd010ab7b2920f777966ba25f7eb63819"
|
||||
],
|
||||
"version": "==2021.8.28"
|
||||
"version": "==2021.9.24"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
@ -1848,16 +1861,24 @@
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.10.2"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e",
|
||||
"sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7",
|
||||
"sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"
|
||||
],
|
||||
"version": "==3.10.0.2"
|
||||
},
|
||||
"urllib3": {
|
||||
"extras": [
|
||||
"secure"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4",
|
||||
"sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"
|
||||
"sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece",
|
||||
"sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.26.6"
|
||||
"version": "==1.26.7"
|
||||
},
|
||||
"wrapt": {
|
||||
"hashes": [
|
||||
|
@ -1,3 +1,3 @@
|
||||
"""authentik"""
|
||||
__version__ = "2021.9.1-rc2"
|
||||
__version__ = "2021.9.6"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
@ -84,7 +84,7 @@ class SystemSerializer(PassiveSerializer):
|
||||
return now()
|
||||
|
||||
def get_embedded_outpost_host(self, request: Request) -> str:
|
||||
"""Get the FQDN configured on the embeddded outpost"""
|
||||
"""Get the FQDN configured on the embedded outpost"""
|
||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||
if not outposts.exists():
|
||||
return ""
|
||||
|
@ -8,3 +8,8 @@ class AuthentikAdminConfig(AppConfig):
|
||||
name = "authentik.admin"
|
||||
label = "authentik_admin"
|
||||
verbose_name = "authentik Admin"
|
||||
|
||||
def ready(self):
|
||||
from authentik.admin.tasks import clear_update_notifications
|
||||
|
||||
clear_update_notifications.delay()
|
||||
|
@ -10,7 +10,7 @@ from requests import RequestException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import ENV_GIT_HASH_KEY, __version__
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.models import Event, EventAction, Notification
|
||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
@ -35,6 +35,18 @@ def _set_prom_info():
|
||||
)
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def clear_update_notifications():
|
||||
"""Clear update notifications on startup if the notification was for the version
|
||||
we're running now."""
|
||||
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
|
||||
if "new_version" not in notification.event.context:
|
||||
continue
|
||||
notification_version = notification.event.context["new_version"]
|
||||
if notification_version == __version__:
|
||||
notification.delete()
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
def update_latest_version(self: MonitoredTask):
|
||||
"""Update latest version info"""
|
||||
|
@ -40,7 +40,6 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
|
||||
raise AuthenticationFailed("Malformed header")
|
||||
tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
|
||||
if not tokens.exists():
|
||||
LOGGER.info("Authenticating via secret_key")
|
||||
user = token_secret_key(password)
|
||||
if not user:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
@ -58,6 +57,7 @@ def token_secret_key(value: str) -> Optional[User]:
|
||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||
if not outposts:
|
||||
return None
|
||||
LOGGER.info("Authenticating via secret_key")
|
||||
outpost = outposts.first()
|
||||
return outpost.user
|
||||
|
||||
|
@ -33,3 +33,12 @@ class OwnerPermissions(BasePermission):
|
||||
if owner != request.user:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class OwnerSuperuserPermissions(OwnerPermissions):
|
||||
"""Similar to OwnerPermissions, except always allow access for superusers"""
|
||||
|
||||
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
|
||||
if request.user.is_superuser:
|
||||
return True
|
||||
return super().has_object_permission(request, view, obj)
|
||||
|
@ -5,6 +5,9 @@ from typing import Callable, Optional
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None):
|
||||
@ -18,10 +21,12 @@ def permission_required(perm: Optional[str] = None, other_perms: Optional[list[s
|
||||
if perm:
|
||||
obj = self.get_object()
|
||||
if not request.user.has_perm(perm, obj):
|
||||
LOGGER.debug("denying access for object", user=request.user, perm=perm, obj=obj)
|
||||
return self.permission_denied(request)
|
||||
if other_perms:
|
||||
for other_perm in other_perms:
|
||||
if not request.user.has_perm(other_perm):
|
||||
LOGGER.debug("denying access for other", user=request.user, perm=perm)
|
||||
return self.permission_denied(request)
|
||||
return func(self, request, *args, **kwargs)
|
||||
|
||||
|
@ -11,7 +11,7 @@ from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
|
||||
def build_standard_type(obj, **kwargs):
|
||||
"""Build a basic type with optional add ons."""
|
||||
"""Build a basic type with optional add owns."""
|
||||
schema = build_basic_type(obj)
|
||||
schema.update(kwargs)
|
||||
return schema
|
||||
|
@ -63,7 +63,7 @@ class ConfigView(APIView):
|
||||
|
||||
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Retrive public configuration options"""
|
||||
"""Retrieve public configuration options"""
|
||||
config = ConfigSerializer(
|
||||
{
|
||||
"error_reporting_enabled": CONFIG.y("error_reporting.enabled"),
|
||||
|
@ -10,10 +10,13 @@ from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.tasks import sentry_proxy
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class PlainTextParser(BaseParser):
|
||||
"""Plain text parser."""
|
||||
@ -45,6 +48,7 @@ class SentryTunnelView(APIView):
|
||||
"""Sentry tunnel, to prevent ad blockers from blocking sentry"""
|
||||
# Only allow usage of this endpoint when error reporting is enabled
|
||||
if not CONFIG.y_bool("error_reporting.enabled", False):
|
||||
LOGGER.debug("error reporting disabled")
|
||||
return HttpResponse(status=400)
|
||||
# Body is 2 json objects separated by \n
|
||||
full_body = request.body
|
||||
@ -55,6 +59,7 @@ class SentryTunnelView(APIView):
|
||||
# Check that the DSN is what we expect
|
||||
dsn = header.get("dsn", "")
|
||||
if dsn != settings.SENTRY_DSN:
|
||||
LOGGER.debug("Invalid dsn", have=dsn, expected=settings.SENTRY_DSN)
|
||||
return HttpResponse(status=400)
|
||||
sentry_proxy.delay(full_body.decode())
|
||||
return HttpResponse(status=204)
|
||||
|
@ -99,6 +99,7 @@ from authentik.stages.user_write.api import UserWriteStageViewSet
|
||||
from authentik.tenants.api import TenantViewSet
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.include_format_suffixes = False
|
||||
|
||||
router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
|
||||
router.register("admin/apps", AppsViewSet, basename="apps")
|
||||
|
@ -11,7 +11,7 @@ from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from ua_parser import user_agent_parser
|
||||
|
||||
from authentik.api.authorization import OwnerPermissions
|
||||
from authentik.api.authorization import OwnerSuperuserPermissions
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
from authentik.events.geo import GEOIP_READER, GeoIPDict
|
||||
@ -103,7 +103,7 @@ class AuthenticatedSessionViewSet(
|
||||
search_fields = ["user__username", "last_ip", "last_user_agent"]
|
||||
filterset_fields = ["user__username", "last_ip", "last_user_agent"]
|
||||
ordering = ["user__username"]
|
||||
permission_classes = [OwnerPermissions]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -2,15 +2,19 @@
|
||||
from typing import Any
|
||||
|
||||
from django.http.response import Http404
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerSuperuserPermissions
|
||||
from authentik.api.decorators import permission_required
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
@ -78,14 +82,25 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
"description",
|
||||
"expires",
|
||||
"expiring",
|
||||
"managed",
|
||||
]
|
||||
ordering = ["expires"]
|
||||
ordering = ["identifier", "expires"]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user if self.request else get_anonymous_user()
|
||||
if user.is_superuser:
|
||||
return super().get_queryset()
|
||||
return super().get_queryset().filter(user=user.pk)
|
||||
|
||||
def perform_create(self, serializer: TokenSerializer):
|
||||
serializer.save(
|
||||
user=self.request.user,
|
||||
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
|
||||
)
|
||||
if not self.request.user.is_superuser:
|
||||
return serializer.save(
|
||||
user=self.request.user,
|
||||
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
|
||||
)
|
||||
return super().perform_create(serializer)
|
||||
|
||||
@permission_required("authentik_core.view_token_key")
|
||||
@extend_schema(
|
||||
|
@ -8,6 +8,7 @@ from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from django_filters.filters import BooleanFilter, CharFilter, ModelMultipleChoiceFilter
|
||||
@ -89,6 +90,9 @@ class UserSerializer(ModelSerializer):
|
||||
"attributes",
|
||||
"uid",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"name": {"allow_blank": True},
|
||||
}
|
||||
|
||||
|
||||
class UserSelfSerializer(ModelSerializer):
|
||||
@ -97,9 +101,25 @@ class UserSelfSerializer(ModelSerializer):
|
||||
|
||||
is_superuser = BooleanField(read_only=True)
|
||||
avatar = CharField(read_only=True)
|
||||
groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
|
||||
groups = SerializerMethodField()
|
||||
uid = CharField(read_only=True)
|
||||
|
||||
@extend_schema_field(
|
||||
ListSerializer(
|
||||
child=inline_serializer(
|
||||
"UserSelfGroups",
|
||||
{"name": CharField(read_only=True), "pk": CharField(read_only=True)},
|
||||
)
|
||||
)
|
||||
)
|
||||
def get_groups(self, user: User):
|
||||
"""Return only the group names a user is member of"""
|
||||
for group in user.ak_groups.all():
|
||||
yield {
|
||||
"name": group.name,
|
||||
"pk": group.pk,
|
||||
}
|
||||
|
||||
class Meta:
|
||||
|
||||
model = User
|
||||
@ -116,6 +136,7 @@ class UserSelfSerializer(ModelSerializer):
|
||||
]
|
||||
extra_kwargs = {
|
||||
"is_active": {"read_only": True},
|
||||
"name": {"allow_blank": True},
|
||||
}
|
||||
|
||||
|
||||
@ -207,6 +228,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"""User Viewset"""
|
||||
|
||||
queryset = User.objects.none()
|
||||
ordering = ["username"]
|
||||
serializer_class = UserSerializer
|
||||
search_fields = ["username", "name", "is_active", "email"]
|
||||
filterset_class = UsersFilter
|
||||
@ -273,7 +295,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
group.users.add(user)
|
||||
token = Token.objects.create(
|
||||
identifier=f"service-account-{username}-password",
|
||||
identifier=slugify(f"service-account-{username}-password"),
|
||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||
user=user,
|
||||
expires=now() + timedelta(days=360),
|
||||
@ -307,7 +329,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"""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)
|
||||
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
|
||||
|
@ -26,7 +26,7 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
(
|
||||
"username_link",
|
||||
"Link to a user with identical username address. Can have security implications when a username is used with another source.",
|
||||
"Link to a user with identical username. Can have security implications when a username is used with another source.",
|
||||
),
|
||||
(
|
||||
"username_deny",
|
||||
|
@ -283,7 +283,7 @@ class SourceUserMatchingModes(models.TextChoices):
|
||||
)
|
||||
USERNAME_LINK = "username_link", _(
|
||||
(
|
||||
"Link to a user with identical username address. Can have security implications "
|
||||
"Link to a user with identical username. Can have security implications "
|
||||
"when a username is used with another source."
|
||||
)
|
||||
)
|
||||
|
@ -1,4 +1,6 @@
|
||||
"""Test token API"""
|
||||
from json import loads
|
||||
|
||||
from django.urls.base import reverse
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
@ -13,7 +15,8 @@ class TestTokenAPI(APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username="akadmin")
|
||||
self.user = User.objects.create(username="testuser")
|
||||
self.admin = User.objects.get(username="akadmin")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_token_create(self):
|
||||
@ -55,3 +58,29 @@ class TestTokenAPI(APITestCase):
|
||||
clean_expired_models.delay().get()
|
||||
token.refresh_from_db()
|
||||
self.assertNotEqual(key, token.key)
|
||||
|
||||
def test_list(self):
|
||||
"""Test Token List (Test normal authentication)"""
|
||||
token_should: Token = Token.objects.create(
|
||||
identifier="test", expiring=False, user=self.user
|
||||
)
|
||||
Token.objects.create(identifier="test-2", expiring=False, user=get_anonymous_user())
|
||||
response = self.client.get(reverse(("authentik_api:token-list")))
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 1)
|
||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
||||
|
||||
def test_list_admin(self):
|
||||
"""Test Token List (Test with admin auth)"""
|
||||
self.client.force_login(self.admin)
|
||||
token_should: Token = Token.objects.create(
|
||||
identifier="test", expiring=False, user=self.user
|
||||
)
|
||||
token_should_not: Token = Token.objects.create(
|
||||
identifier="test-2", expiring=False, user=get_anonymous_user()
|
||||
)
|
||||
response = self.client.get(reverse(("authentik_api:token-list")))
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
||||
self.assertEqual(body["results"][1]["identifier"], token_should_not.identifier)
|
||||
|
@ -1,7 +1,10 @@
|
||||
"""NotificationTransport API Views"""
|
||||
from typing import Any
|
||||
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, ListField, SerializerMethodField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@ -29,6 +32,14 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
"""Return selected mode with a UI Label"""
|
||||
return TransportMode(instance.mode).label
|
||||
|
||||
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
|
||||
"""Ensure the required fields are set."""
|
||||
mode = attrs.get("mode")
|
||||
if mode in [TransportMode.WEBHOOK, TransportMode.WEBHOOK_SLACK]:
|
||||
if "webhook_url" not in attrs or attrs.get("webhook_url", "") == "":
|
||||
raise ValidationError("Webhook URL may not be empty.")
|
||||
return attrs
|
||||
|
||||
class Meta:
|
||||
|
||||
model = NotificationTransport
|
||||
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.7 on 2021-10-04 15:31
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0018_auto_20210911_2217"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notificationtransport",
|
||||
name="webhook_url",
|
||||
field=models.TextField(blank=True, validators=[django.core.validators.URLValidator()]),
|
||||
),
|
||||
]
|
@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional, Type, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.http.request import QueryDict
|
||||
@ -223,7 +224,7 @@ class NotificationTransport(models.Model):
|
||||
name = models.TextField(unique=True)
|
||||
mode = models.TextField(choices=TransportMode.choices)
|
||||
|
||||
webhook_url = models.TextField(blank=True)
|
||||
webhook_url = models.TextField(blank=True, validators=[URLValidator()])
|
||||
webhook_mapping = models.ForeignKey(
|
||||
"NotificationWebhookMapping", on_delete=models.SET_DEFAULT, null=True, default=None
|
||||
)
|
||||
|
@ -3,7 +3,6 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from timeit import default_timer
|
||||
from traceback import format_tb
|
||||
from typing import Any, Optional
|
||||
|
||||
from celery import Task
|
||||
@ -42,8 +41,7 @@ class TaskResult:
|
||||
|
||||
def with_error(self, exc: Exception) -> "TaskResult":
|
||||
"""Since errors might not always be pickle-able, set the traceback"""
|
||||
self.messages.extend(format_tb(exc.__traceback__))
|
||||
self.messages.append(str(exc))
|
||||
self.messages.extend(exception_to_string(exc).splitlines())
|
||||
return self
|
||||
|
||||
|
||||
|
@ -4,7 +4,13 @@ from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction, Notification, NotificationSeverity
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
EventAction,
|
||||
Notification,
|
||||
NotificationSeverity,
|
||||
TransportMode,
|
||||
)
|
||||
|
||||
|
||||
class TestEventsAPI(APITestCase):
|
||||
@ -41,3 +47,23 @@ class TestEventsAPI(APITestCase):
|
||||
)
|
||||
notification.refresh_from_db()
|
||||
self.assertTrue(notification.seen)
|
||||
|
||||
def test_transport(self):
|
||||
"""Test transport API"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:notificationtransport-list"),
|
||||
data={
|
||||
"name": "foo-with",
|
||||
"mode": TransportMode.WEBHOOK,
|
||||
"webhook_url": "http://foo.com",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:notificationtransport-list"),
|
||||
data={
|
||||
"name": "foo-without",
|
||||
"mode": TransportMode.WEBHOOK,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
@ -77,7 +77,7 @@ def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
||||
final_dict = {}
|
||||
for key, value in source.items():
|
||||
if is_dataclass(value):
|
||||
# Because asdict calls `copy.deepcopy(obj)` on everything thats not tuple/dict,
|
||||
# Because asdict calls `copy.deepcopy(obj)` on everything that's not tuple/dict,
|
||||
# and deepcopy doesn't work with HttpRequests (neither django nor rest_framework).
|
||||
# Currently, the only dataclass that actually holds an http request is a PolicyRequest
|
||||
if isinstance(value, PolicyRequest):
|
||||
|
@ -108,6 +108,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = Flow.objects.all()
|
||||
serializer_class = FlowSerializer
|
||||
lookup_field = "slug"
|
||||
ordering = ["slug", "name"]
|
||||
search_fields = ["name", "slug", "designation", "title"]
|
||||
filterset_fields = ["flow_uuid", "name", "slug", "designation"]
|
||||
|
||||
|
@ -57,11 +57,11 @@ class FlowPlan:
|
||||
markers: list[StageMarker] = field(default_factory=list)
|
||||
|
||||
def append_stage(self, stage: Stage, marker: Optional[StageMarker] = None):
|
||||
"""Append `stage` to all stages, optionall with stage marker"""
|
||||
"""Append `stage` to all stages, optionally with stage marker"""
|
||||
return self.append(FlowStageBinding(stage=stage), marker)
|
||||
|
||||
def append(self, binding: FlowStageBinding, marker: Optional[StageMarker] = None):
|
||||
"""Append `stage` to all stages, optionall with stage marker"""
|
||||
"""Append `stage` to all stages, optionally with stage marker"""
|
||||
self.bindings.append(binding)
|
||||
self.markers.append(marker or StageMarker())
|
||||
|
||||
|
@ -438,7 +438,7 @@ class TestFlowExecutor(APITestCase):
|
||||
|
||||
# third request, this should trigger the re-evaluate
|
||||
# A get request will evaluate the policies and this will return stage 4
|
||||
# but it won't save it, hence we cant' check the plan
|
||||
# but it won't save it, hence we can't check the plan
|
||||
response = self.client.get(exec_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
|
@ -11,7 +11,7 @@ from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
|
||||
def get_attrs(obj: SerializerModel) -> dict[str, Any]:
|
||||
"""Get object's attributes via their serializer, and covert it to a normal dict"""
|
||||
"""Get object's attributes via their serializer, and convert it to a normal dict"""
|
||||
data = dict(obj.serializer(obj).data)
|
||||
to_remove = (
|
||||
"policies",
|
||||
|
@ -14,12 +14,7 @@ from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.generic import View
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiParameter,
|
||||
OpenApiResponse,
|
||||
PolymorphicProxySerializer,
|
||||
extend_schema,
|
||||
)
|
||||
from drf_spectacular.utils import OpenApiParameter, PolymorphicProxySerializer, extend_schema
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.views import APIView
|
||||
from sentry_sdk import capture_exception
|
||||
@ -131,12 +126,12 @@ class FlowExecutorView(APIView):
|
||||
|
||||
# pylint: disable=unused-argument, too-many-return-statements
|
||||
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
|
||||
# Early check if theres an active Plan for the current session
|
||||
# Early check if there's an active Plan for the current session
|
||||
if SESSION_KEY_PLAN in self.request.session:
|
||||
self.plan = self.request.session[SESSION_KEY_PLAN]
|
||||
if self.plan.flow_pk != self.flow.pk.hex:
|
||||
self._logger.warning(
|
||||
"f(exec): Found existing plan for other flow, deleteing plan",
|
||||
"f(exec): Found existing plan for other flow, deleting plan",
|
||||
)
|
||||
# Existing plan is deleted from session and instance
|
||||
self.plan = None
|
||||
@ -213,9 +208,6 @@ class FlowExecutorView(APIView):
|
||||
serializers=challenge_types(),
|
||||
resource_type_field_name="component",
|
||||
),
|
||||
404: OpenApiResponse(
|
||||
description="No Token found"
|
||||
), # This error can be raised by the email stage
|
||||
},
|
||||
request=OpenApiTypes.NONE,
|
||||
parameters=[
|
||||
@ -441,7 +433,7 @@ class ToDefaultFlow(View):
|
||||
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||
if plan.flow_pk != flow.pk.hex:
|
||||
LOGGER.warning(
|
||||
"f(def): Found existing plan for other flow, deleteing plan",
|
||||
"f(def): Found existing plan for other flow, deleting plan",
|
||||
flow_slug=flow.slug,
|
||||
)
|
||||
del self.request.session[SESSION_KEY_PLAN]
|
||||
|
@ -93,6 +93,7 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
|
||||
if "exc_info" in hint:
|
||||
_, exc_value, _ = hint["exc_info"]
|
||||
if isinstance(exc_value, ignored_classes):
|
||||
LOGGER.debug("dropping exception", exception=exc_value)
|
||||
return None
|
||||
if "logger" in event:
|
||||
if event["logger"] in [
|
||||
|
@ -32,7 +32,7 @@ class TestConfig(TestCase):
|
||||
config = ConfigLoader()
|
||||
environ["foo"] = "bar"
|
||||
self.assertEqual(config.parse_uri("env://foo"), "bar")
|
||||
self.assertEqual(config.parse_uri("env://fo?bar"), "bar")
|
||||
self.assertEqual(config.parse_uri("env://foo?bar"), "bar")
|
||||
|
||||
def test_uri_file(self):
|
||||
"""Test URI parsing (file load)"""
|
||||
|
@ -27,7 +27,7 @@ class TestHTTP(TestCase):
|
||||
token = Token.objects.create(
|
||||
identifier="test", user=self.user, intent=TokenIntents.INTENT_API
|
||||
)
|
||||
# Invalid, non-existant token
|
||||
# Invalid, non-existent token
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
**{
|
||||
@ -36,7 +36,7 @@ class TestHTTP(TestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(get_client_ip(request), "127.0.0.1")
|
||||
# Invalid, user doesn't have permisions
|
||||
# Invalid, user doesn't have permissions
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
**{
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""authentik lib reflection utilities"""
|
||||
from importlib import import_module
|
||||
from typing import Union
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
@ -19,12 +20,12 @@ def all_subclasses(cls, sort=True):
|
||||
return classes
|
||||
|
||||
|
||||
def class_to_path(cls):
|
||||
def class_to_path(cls: type) -> str:
|
||||
"""Turn Class (Class or instance) into module path"""
|
||||
return f"{cls.__module__}.{cls.__name__}"
|
||||
|
||||
|
||||
def path_to_class(path):
|
||||
def path_to_class(path: Union[str, None]) -> Union[type, None]:
|
||||
"""Import module and return class"""
|
||||
if not path:
|
||||
return None
|
||||
|
@ -104,7 +104,7 @@ class OutpostConsumer(AuthJsonConsumer):
|
||||
expected=self.outpost.config.kubernetes_replicas,
|
||||
).inc()
|
||||
LOGGER.debug(
|
||||
"added outpost instace to cache",
|
||||
"added outpost instance to cache",
|
||||
outpost=self.outpost,
|
||||
instance_uuid=self.last_uid,
|
||||
)
|
||||
|
@ -38,6 +38,7 @@ class DockerController(BaseController):
|
||||
"AUTHENTIK_HOST": self.outpost.config.authentik_host.lower(),
|
||||
"AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure).lower(),
|
||||
"AUTHENTIK_TOKEN": self.outpost.token.key,
|
||||
"AUTHENTIK_HOST_BROWSER": self.outpost.config.authentik_host_browser,
|
||||
}
|
||||
|
||||
def _comp_env(self, container: Container) -> bool:
|
||||
@ -75,6 +76,9 @@ class DockerController(BaseController):
|
||||
# {'HostIp': '0.0.0.0', 'HostPort': '389'},
|
||||
# {'HostIp': '::', 'HostPort': '389'}
|
||||
# ]}
|
||||
# If no ports are mapped (either mapping disabled, or host network)
|
||||
if not container.ports:
|
||||
return False
|
||||
for port in self.deployment_ports:
|
||||
key = f"{port.inner_port or port.port}/{port.protocol.lower()}"
|
||||
if key not in container.ports:
|
||||
@ -98,15 +102,16 @@ class DockerController(BaseController):
|
||||
"image": image_name,
|
||||
"name": container_name,
|
||||
"detach": True,
|
||||
"ports": {
|
||||
f"{port.inner_port or port.port}/{port.protocol.lower()}": port.port
|
||||
for port in self.deployment_ports
|
||||
},
|
||||
"environment": self._get_env(),
|
||||
"labels": self._get_labels(),
|
||||
"restart_policy": {"Name": "unless-stopped"},
|
||||
"network": self.outpost.config.docker_network,
|
||||
}
|
||||
if self.outpost.config.docker_map_ports:
|
||||
container_args["ports"] = {
|
||||
f"{port.inner_port or port.port}/{port.protocol.lower()}": str(port.port)
|
||||
for port in self.deployment_ports
|
||||
}
|
||||
if settings.TEST:
|
||||
del container_args["ports"]
|
||||
del container_args["network"]
|
||||
@ -164,11 +169,9 @@ class DockerController(BaseController):
|
||||
self.down()
|
||||
return self.up(depth + 1)
|
||||
# Check that container is healthy
|
||||
if (
|
||||
container.status == "running"
|
||||
and container.attrs.get("State", {}).get("Health", {}).get("Status", "")
|
||||
!= "healthy"
|
||||
):
|
||||
if container.status == "running" and container.attrs.get("State", {}).get(
|
||||
"Health", {}
|
||||
).get("Status", "") not in ["healthy", "starting"]:
|
||||
# At this point we know the config is correct, but the container isn't healthy,
|
||||
# so we just restart it with the same config
|
||||
if has_been_created:
|
||||
@ -217,6 +220,7 @@ class DockerController(BaseController):
|
||||
"AUTHENTIK_HOST": self.outpost.config.authentik_host,
|
||||
"AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure),
|
||||
"AUTHENTIK_TOKEN": self.outpost.token.key,
|
||||
"AUTHENTIK_HOST_BROWSER": self.outpost.config.authentik_host_browser,
|
||||
},
|
||||
"labels": self._get_labels(),
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ from structlog.stdlib import get_logger
|
||||
from urllib3.exceptions import HTTPError
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpdate
|
||||
from authentik.outposts.managed import MANAGED_OUTPOST
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -20,18 +20,6 @@ if TYPE_CHECKING:
|
||||
T = TypeVar("T", V1Pod, V1Deployment)
|
||||
|
||||
|
||||
class ReconcileTrigger(SentryIgnoredException):
|
||||
"""Base trigger raised by child classes to notify us"""
|
||||
|
||||
|
||||
class NeedsRecreate(ReconcileTrigger):
|
||||
"""Exception to trigger a complete recreate of the Kubernetes Object"""
|
||||
|
||||
|
||||
class NeedsUpdate(ReconcileTrigger):
|
||||
"""Exception to trigger an update to the Kubernetes Object"""
|
||||
|
||||
|
||||
class KubernetesObjectReconciler(Generic[T]):
|
||||
"""Base Kubernetes Reconciler, handles the basic logic."""
|
||||
|
||||
@ -109,7 +97,7 @@ class KubernetesObjectReconciler(Generic[T]):
|
||||
except (OpenApiException, HTTPError) as exc:
|
||||
# pylint: disable=no-member
|
||||
if isinstance(exc, ApiException) and exc.status == 404:
|
||||
self.logger.debug("Failed to get current, assuming non-existant")
|
||||
self.logger.debug("Failed to get current, assuming non-existent")
|
||||
return
|
||||
self.logger.debug("Other unhandled error", exc=exc)
|
||||
raise exc
|
||||
@ -129,7 +117,7 @@ class KubernetesObjectReconciler(Generic[T]):
|
||||
raise NotImplementedError
|
||||
|
||||
def retrieve(self) -> T:
|
||||
"""API Wrapper to retrive object"""
|
||||
"""API Wrapper to retrieve object"""
|
||||
raise NotImplementedError
|
||||
|
||||
def delete(self, reference: T):
|
||||
|
@ -17,7 +17,9 @@ from kubernetes.client import (
|
||||
)
|
||||
|
||||
from authentik.outposts.controllers.base import FIELD_MANAGER
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler, NeedsUpdate
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||
from authentik.outposts.controllers.k8s.triggers import NeedsUpdate
|
||||
from authentik.outposts.controllers.k8s.utils import compare_ports
|
||||
from authentik.outposts.models import Outpost
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -35,7 +37,10 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
||||
self.outpost = self.controller.outpost
|
||||
|
||||
def reconcile(self, current: V1Deployment, reference: V1Deployment):
|
||||
super().reconcile(current, reference)
|
||||
compare_ports(
|
||||
current.spec.template.spec.containers[0].ports,
|
||||
reference.spec.template.spec.containers[0].ports,
|
||||
)
|
||||
if current.spec.replicas != reference.spec.replicas:
|
||||
raise NeedsUpdate()
|
||||
if (
|
||||
@ -43,6 +48,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
||||
!= reference.spec.template.spec.containers[0].image
|
||||
):
|
||||
raise NeedsUpdate()
|
||||
super().reconcile(current, reference)
|
||||
|
||||
def get_pod_meta(self) -> dict[str, str]:
|
||||
"""Get common object metadata"""
|
||||
@ -89,6 +95,15 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
||||
)
|
||||
),
|
||||
),
|
||||
V1EnvVar(
|
||||
name="AUTHENTIK_HOST_BROWSER",
|
||||
value_from=V1EnvVarSource(
|
||||
secret_key_ref=V1SecretKeySelector(
|
||||
name=self.name,
|
||||
key="authentik_host_browser",
|
||||
)
|
||||
),
|
||||
),
|
||||
V1EnvVar(
|
||||
name="AUTHENTIK_TOKEN",
|
||||
value_from=V1EnvVarSource(
|
||||
|
@ -5,7 +5,8 @@ from typing import TYPE_CHECKING
|
||||
from kubernetes.client import CoreV1Api, V1Secret
|
||||
|
||||
from authentik.outposts.controllers.base import FIELD_MANAGER
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler, NeedsUpdate
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||
from authentik.outposts.controllers.k8s.triggers import NeedsUpdate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
@ -26,7 +27,7 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
|
||||
def reconcile(self, current: V1Secret, reference: V1Secret):
|
||||
super().reconcile(current, reference)
|
||||
for key in reference.data.keys():
|
||||
if current.data[key] != reference.data[key]:
|
||||
if key not in current.data or current.data[key] != reference.data[key]:
|
||||
raise NeedsUpdate()
|
||||
|
||||
def get_reference_object(self) -> V1Secret:
|
||||
@ -40,6 +41,9 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
|
||||
str(self.controller.outpost.config.authentik_host_insecure)
|
||||
),
|
||||
"token": b64string(self.controller.outpost.token.key),
|
||||
"authentik_host_browser": b64string(
|
||||
self.controller.outpost.config.authentik_host_browser
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -4,8 +4,9 @@ from typing import TYPE_CHECKING
|
||||
from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec
|
||||
|
||||
from authentik.outposts.controllers.base import FIELD_MANAGER
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler, NeedsRecreate
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
|
||||
from authentik.outposts.controllers.k8s.utils import compare_ports
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
@ -19,12 +20,12 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
|
||||
self.api = CoreV1Api(controller.client)
|
||||
|
||||
def reconcile(self, current: V1Service, reference: V1Service):
|
||||
compare_ports(current.spec.ports, reference.spec.ports)
|
||||
# run the base reconcile last, as that will probably raise NeedsUpdate
|
||||
# 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
|
||||
# priority than being updated.
|
||||
super().reconcile(current, reference)
|
||||
if len(current.spec.ports) != len(reference.spec.ports):
|
||||
raise NeedsRecreate()
|
||||
for port in reference.spec.ports:
|
||||
if port not in current.spec.ports:
|
||||
raise NeedsRecreate()
|
||||
|
||||
def get_reference_object(self) -> V1Service:
|
||||
"""Get deployment object for outpost"""
|
||||
|
14
authentik/outposts/controllers/k8s/triggers.py
Normal file
14
authentik/outposts/controllers/k8s/triggers.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""exceptions used by the kubernetes reconciler to trigger updates"""
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
|
||||
class ReconcileTrigger(SentryIgnoredException):
|
||||
"""Base trigger raised by child classes to notify us"""
|
||||
|
||||
|
||||
class NeedsRecreate(ReconcileTrigger):
|
||||
"""Exception to trigger a complete recreate of the Kubernetes Object"""
|
||||
|
||||
|
||||
class NeedsUpdate(ReconcileTrigger):
|
||||
"""Exception to trigger an update to the Kubernetes Object"""
|
@ -1,8 +1,11 @@
|
||||
"""k8s utils"""
|
||||
from pathlib import Path
|
||||
|
||||
from kubernetes.client.models.v1_container_port import V1ContainerPort
|
||||
from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
|
||||
|
||||
from authentik.outposts.controllers.k8s.triggers import NeedsRecreate
|
||||
|
||||
|
||||
def get_namespace() -> str:
|
||||
"""Get the namespace if we're running in a pod, otherwise default to default"""
|
||||
@ -11,3 +14,12 @@ def get_namespace() -> str:
|
||||
with open(path, "r", encoding="utf8") as _namespace_file:
|
||||
return _namespace_file.read()
|
||||
return "default"
|
||||
|
||||
|
||||
def compare_ports(current: list[V1ContainerPort], reference: list[V1ContainerPort]):
|
||||
"""Compare ports of a list"""
|
||||
if len(current) != len(reference):
|
||||
raise NeedsRecreate()
|
||||
for port in reference:
|
||||
if port not in current:
|
||||
raise NeedsRecreate()
|
||||
|
@ -30,7 +30,7 @@ class DockerInlineTLS:
|
||||
return str(path)
|
||||
|
||||
def write(self) -> TLSConfig:
|
||||
"""Create TLSConfig with Certificate Keypairs"""
|
||||
"""Create TLSConfig with Certificate Key pairs"""
|
||||
# So yes, this is quite ugly. But sadly, there is no clean way to pass
|
||||
# docker-py (which is using requests (which is using urllib3)) a certificate
|
||||
# for verification or authentication as string.
|
||||
|
@ -64,6 +64,7 @@ class OutpostConfig:
|
||||
|
||||
authentik_host: str = ""
|
||||
authentik_host_insecure: bool = False
|
||||
authentik_host_browser: str = ""
|
||||
|
||||
log_level: str = CONFIG.y("log_level")
|
||||
error_reporting_enabled: bool = CONFIG.y_bool("error_reporting.enabled")
|
||||
@ -71,6 +72,7 @@ class OutpostConfig:
|
||||
object_naming_template: str = field(default="ak-outpost-%(name)s")
|
||||
|
||||
docker_network: Optional[str] = field(default=None)
|
||||
docker_map_ports: bool = field(default=True)
|
||||
|
||||
kubernetes_replicas: int = field(default=1)
|
||||
kubernetes_namespace: str = field(default_factory=get_namespace)
|
||||
@ -339,19 +341,8 @@ class Outpost(ManagedModel):
|
||||
"""Username for service user"""
|
||||
return f"ak-outpost-{self.uuid.hex}"
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
"""Get/create user with access to all required objects"""
|
||||
users = User.objects.filter(username=self.user_identifier)
|
||||
if not users.exists():
|
||||
user: User = User.objects.create(username=self.user_identifier)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
else:
|
||||
user = users.first()
|
||||
user.attributes[USER_ATTRIBUTE_SA] = True
|
||||
user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
|
||||
user.save()
|
||||
def build_user_permissions(self, user: User):
|
||||
"""Create per-object and global permissions for outpost service-account"""
|
||||
# To ensure the user only has the correct permissions, we delete all of them and re-add
|
||||
# the ones the user needs
|
||||
with transaction.atomic():
|
||||
@ -395,6 +386,23 @@ class Outpost(ManagedModel):
|
||||
"Updated service account's permissions",
|
||||
perms=UserObjectPermission.objects.filter(user=user),
|
||||
)
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
"""Get/create user with access to all required objects"""
|
||||
users = User.objects.filter(username=self.user_identifier)
|
||||
should_create_user = not users.exists()
|
||||
if should_create_user:
|
||||
user: User = User.objects.create(username=self.user_identifier)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
else:
|
||||
user = users.first()
|
||||
user.attributes[USER_ATTRIBUTE_SA] = True
|
||||
user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
|
||||
user.save()
|
||||
if should_create_user:
|
||||
self.build_user_permissions(user)
|
||||
return user
|
||||
|
||||
@property
|
||||
|
@ -126,6 +126,7 @@ def outpost_token_ensurer(self: MonitoredTask):
|
||||
all_outposts = Outpost.objects.all()
|
||||
for outpost in all_outposts:
|
||||
_ = outpost.token
|
||||
outpost.build_user_permissions(outpost.user)
|
||||
self.set_status(
|
||||
TaskResult(
|
||||
TaskResultStatus.SUCCESSFUL,
|
||||
@ -181,7 +182,7 @@ def outpost_post_save(model_class: str, model_pk: Any):
|
||||
|
||||
|
||||
def outpost_send_update(model_instace: Model):
|
||||
"""Send outpost update to all registered outposts, irregardless to which authentik
|
||||
"""Send outpost update to all registered outposts, regardless to which authentik
|
||||
instance they are connected"""
|
||||
channel_layer = get_channel_layer()
|
||||
if isinstance(model_instace, OutpostModel):
|
||||
@ -196,7 +197,7 @@ def _outpost_single_update(outpost: Outpost, layer=None):
|
||||
# Ensure token again, because this function is called when anything related to an
|
||||
# OutpostModel is saved, so we can be sure permissions are right
|
||||
_ = outpost.token
|
||||
_ = outpost.user
|
||||
outpost.build_user_permissions(outpost.user)
|
||||
if not layer: # pragma: no cover
|
||||
layer = get_channel_layer()
|
||||
for state in OutpostState.for_outpost(outpost):
|
||||
@ -208,7 +209,7 @@ def _outpost_single_update(outpost: Outpost, layer=None):
|
||||
@CELERY_APP.task()
|
||||
def outpost_local_connection():
|
||||
"""Checks the local environment and create Service connections."""
|
||||
# Explicitly check against token filename, as thats
|
||||
# Explicitly check against token filename, as that's
|
||||
# only present when the integration is enabled
|
||||
if Path(SERVICE_TOKEN_FILENAME).exists():
|
||||
LOGGER.debug("Detected in-cluster Kubernetes Config")
|
||||
|
@ -87,6 +87,7 @@ class PolicyViewSet(
|
||||
"promptstage": ["isnull"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
||||
def get_queryset(self): # pragma: no cover
|
||||
return Policy.objects.select_subclasses().prefetch_related("bindings", "promptstage_set")
|
||||
|
@ -3,8 +3,10 @@ from ipaddress import ip_address, ip_network
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django_otp import devices_for_user
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.planner import PLAN_CONTEXT_SSO
|
||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
@ -28,6 +30,7 @@ class PolicyEvaluator(BaseEvaluator):
|
||||
self._messages = []
|
||||
self._context["ak_logger"] = get_logger(policy_name)
|
||||
self._context["ak_message"] = self.expr_func_message
|
||||
self._context["ak_user_has_authenticator"] = self.expr_func_user_has_authenticator
|
||||
self._context["ip_address"] = ip_address
|
||||
self._context["ip_network"] = ip_network
|
||||
self._filename = policy_name or "PolicyEvaluator"
|
||||
@ -36,6 +39,19 @@ class PolicyEvaluator(BaseEvaluator):
|
||||
"""Wrapper to append to messages list, which is returned with PolicyResult"""
|
||||
self._messages.append(message)
|
||||
|
||||
def expr_func_user_has_authenticator(
|
||||
self, user: User, device_type: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Check if a user has any authenticator devices, optionally matching *device_type*"""
|
||||
user_devices = devices_for_user(user)
|
||||
if device_type:
|
||||
for device in user_devices:
|
||||
device_class = device.__class__.__name__.lower().replace("device", "")
|
||||
if device_class == device_type:
|
||||
return True
|
||||
return False
|
||||
return len(user_devices) > 0
|
||||
|
||||
def set_policy_request(self, request: PolicyRequest):
|
||||
"""Update context based on policy request (if http request is given, update that too)"""
|
||||
# update website/docs/expressions/_objects.md
|
||||
|
@ -46,7 +46,7 @@ def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
|
||||
|
||||
|
||||
class PolicyProcess(PROCESS_CLASS):
|
||||
"""Evaluate a single policy within a seprate process"""
|
||||
"""Evaluate a single policy within a separate process"""
|
||||
|
||||
connection: Connection
|
||||
binding: PolicyBinding
|
||||
|
@ -34,7 +34,7 @@ def update_score(request: HttpRequest, username: str, amount: int):
|
||||
@receiver(user_login_failed)
|
||||
# pylint: disable=unused-argument
|
||||
def handle_failed_login(sender, request, credentials, **_):
|
||||
"""Lower Score for failed loging attempts"""
|
||||
"""Lower Score for failed login attempts"""
|
||||
if "username" in credentials:
|
||||
update_score(request, credentials.get("username"), -1)
|
||||
|
||||
|
@ -14,7 +14,7 @@ from authentik.policies.types import PolicyRequest
|
||||
|
||||
|
||||
def clear_policy_cache():
|
||||
"""Ensure no policy-related keys are stil cached"""
|
||||
"""Ensure no policy-related keys are still cached"""
|
||||
keys = cache.keys("policy_*")
|
||||
cache.delete(keys)
|
||||
|
||||
|
@ -1,16 +1,15 @@
|
||||
"""LDAP Provider Docker Contoller"""
|
||||
"""LDAP Provider Docker Controller"""
|
||||
from authentik.outposts.controllers.base import DeploymentPort
|
||||
from authentik.outposts.controllers.docker import DockerController
|
||||
from authentik.outposts.models import DockerServiceConnection, Outpost
|
||||
|
||||
|
||||
class LDAPDockerController(DockerController):
|
||||
"""LDAP Provider Docker Contoller"""
|
||||
"""LDAP Provider Docker Controller"""
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: DockerServiceConnection):
|
||||
super().__init__(outpost, connection)
|
||||
self.deployment_ports = [
|
||||
DeploymentPort(389, "ldap", "tcp", 3389),
|
||||
DeploymentPort(636, "ldaps", "tcp", 6636),
|
||||
DeploymentPort(9300, "http-metrics", "tcp", 9300),
|
||||
]
|
||||
|
@ -1,11 +1,11 @@
|
||||
"""LDAP Provider Kubernetes Contoller"""
|
||||
"""LDAP Provider Kubernetes Controller"""
|
||||
from authentik.outposts.controllers.base import DeploymentPort
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost
|
||||
|
||||
|
||||
class LDAPKubernetesController(KubernetesController):
|
||||
"""LDAP Provider Kubernetes Contoller"""
|
||||
"""LDAP Provider Kubernetes Controller"""
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection):
|
||||
super().__init__(outpost, connection)
|
||||
|
@ -153,7 +153,7 @@ def protected_resource_view(scopes: list[str]):
|
||||
|
||||
if not set(scopes).issubset(set(token.scope)):
|
||||
LOGGER.warning(
|
||||
"Scope missmatch.",
|
||||
"Scope mismatch.",
|
||||
required=set(scopes),
|
||||
token_has=set(token.scope),
|
||||
)
|
||||
|
@ -33,7 +33,7 @@ class UserInfoView(View):
|
||||
for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by("scope_name"):
|
||||
if scope.description != "":
|
||||
scope_descriptions.append({"id": scope.scope_name, "name": scope.description})
|
||||
# GitHub Compatibility Scopes are handeled differently, since they required custom paths
|
||||
# GitHub Compatibility Scopes are handled differently, since they required custom paths
|
||||
# Hence they don't exist as Scope objects
|
||||
github_scope_map = {
|
||||
SCOPE_GITHUB_USER: ("GitHub Compatibility: Access your User Information"),
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""ProxyProvider API Views"""
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework.exceptions import ValidationError
|
||||
@ -10,6 +10,7 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.views.provider import ProviderInfoView
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
|
||||
@ -106,6 +107,16 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
||||
"""Proxy provider serializer for outposts"""
|
||||
|
||||
oidc_configuration = SerializerMethodField()
|
||||
token_validity = SerializerMethodField()
|
||||
|
||||
@extend_schema_field(OpenIDConnectConfigurationSerializer)
|
||||
def get_oidc_configuration(self, obj: ProxyProvider):
|
||||
"""Embed OpenID Connect provider information"""
|
||||
return ProviderInfoView(request=self.context["request"]._request).get_info(obj)
|
||||
|
||||
def get_token_validity(self, obj: ProxyProvider) -> Optional[float]:
|
||||
"""Get token validity as second count"""
|
||||
return timedelta_from_string(obj.token_validity).total_seconds()
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -127,13 +138,9 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
||||
"basic_auth_user_attribute",
|
||||
"mode",
|
||||
"cookie_domain",
|
||||
"token_validity",
|
||||
]
|
||||
|
||||
@extend_schema_field(OpenIDConnectConfigurationSerializer)
|
||||
def get_oidc_configuration(self, obj: ProxyProvider):
|
||||
"""Embed OpenID Connect provider information"""
|
||||
return ProviderInfoView(request=self.context["request"]._request).get_info(obj)
|
||||
|
||||
|
||||
class ProxyOutpostConfigViewSet(ReadOnlyModelViewSet):
|
||||
"""ProxyProvider Viewset"""
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Proxy Provider Docker Contoller"""
|
||||
"""Proxy Provider Docker Controller"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from authentik.outposts.controllers.base import DeploymentPort
|
||||
@ -8,13 +8,12 @@ from authentik.providers.proxy.models import ProxyProvider
|
||||
|
||||
|
||||
class ProxyDockerController(DockerController):
|
||||
"""Proxy Provider Docker Contoller"""
|
||||
"""Proxy Provider Docker Controller"""
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: DockerServiceConnection):
|
||||
super().__init__(outpost, connection)
|
||||
self.deployment_ports = [
|
||||
DeploymentPort(9000, "http", "tcp"),
|
||||
DeploymentPort(9300, "http-metrics", "tcp"),
|
||||
DeploymentPort(9443, "https", "tcp"),
|
||||
]
|
||||
|
||||
@ -30,6 +29,11 @@ class ProxyDockerController(DockerController):
|
||||
labels[f"traefik.http.routers.{traefik_name}-router.rule"] = f"Host({','.join(hosts)})"
|
||||
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.services.{traefik_name}-service.loadbalancer.healthcheck.path"] = "/"
|
||||
labels[
|
||||
f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path"
|
||||
] = "/akprox/ping"
|
||||
labels[
|
||||
f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.port"
|
||||
] = "9300"
|
||||
labels[f"traefik.http.services.{traefik_name}-service.loadbalancer.server.port"] = "9000"
|
||||
return labels
|
||||
|
@ -14,11 +14,8 @@ from kubernetes.client import (
|
||||
from kubernetes.client.models.networking_v1beta1_ingress_rule import NetworkingV1beta1IngressRule
|
||||
|
||||
from authentik.outposts.controllers.base import FIELD_MANAGER
|
||||
from authentik.outposts.controllers.k8s.base import (
|
||||
KubernetesObjectReconciler,
|
||||
NeedsRecreate,
|
||||
NeedsUpdate,
|
||||
)
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||
from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpdate
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -63,8 +60,15 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]):
|
||||
have_hosts_tls = []
|
||||
if current.spec.tls:
|
||||
for tls_config in current.spec.tls:
|
||||
if tls_config and tls_config.hosts:
|
||||
if not tls_config:
|
||||
continue
|
||||
if tls_config.hosts:
|
||||
have_hosts_tls += tls_config.hosts
|
||||
if (
|
||||
tls_config.secret_name
|
||||
!= self.controller.outpost.config.kubernetes_ingress_secret_name
|
||||
):
|
||||
raise NeedsUpdate()
|
||||
have_hosts_tls.sort()
|
||||
|
||||
if have_hosts != expected_hosts:
|
||||
|
@ -6,7 +6,8 @@ from dacite import from_dict
|
||||
from kubernetes.client import ApiextensionsV1Api, CustomObjectsApi
|
||||
|
||||
from authentik.outposts.controllers.base import FIELD_MANAGER
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler, NeedsUpdate
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||
from authentik.outposts.controllers.k8s.triggers import NeedsUpdate
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -109,11 +110,18 @@ class TraefikMiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware])
|
||||
address=f"http://{self.name}.{self.namespace}:9000/akprox/auth/traefik",
|
||||
authResponseHeaders=[
|
||||
"Set-Cookie",
|
||||
# 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-groups",
|
||||
"X-authentik-email",
|
||||
"X-authentik-name",
|
||||
"X-authentik-uid",
|
||||
],
|
||||
trustForwardHeader=True,
|
||||
)
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Proxy Provider Kubernetes Contoller"""
|
||||
"""Proxy Provider Kubernetes Controller"""
|
||||
from authentik.outposts.controllers.base import DeploymentPort
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost
|
||||
@ -7,7 +7,7 @@ from authentik.providers.proxy.controllers.k8s.traefik import TraefikMiddlewareR
|
||||
|
||||
|
||||
class ProxyKubernetesController(KubernetesController):
|
||||
"""Proxy Provider Kubernetes Contoller"""
|
||||
"""Proxy Provider Kubernetes Controller"""
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection):
|
||||
super().__init__(outpost, connection)
|
||||
|
@ -4,6 +4,7 @@ from getpass import getuser
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog.stdlib import get_logger
|
||||
@ -42,7 +43,7 @@ class Command(BaseCommand):
|
||||
user=user,
|
||||
intent=TokenIntents.INTENT_RECOVERY,
|
||||
description=f"Recovery Token generated by {getuser()} on {_now}",
|
||||
identifier=f"ak-recovery-{user}-{_now}",
|
||||
identifier=slugify(f"ak-recovery-{user}-{_now}"),
|
||||
)
|
||||
self.stdout.write(
|
||||
(f"Store this link safely, as it will allow" f" anyone to access authentik as {user}.")
|
||||
|
@ -3,10 +3,20 @@ import os
|
||||
from logging.config import dictConfig
|
||||
|
||||
from celery import Celery
|
||||
from celery.signals import after_task_publish, setup_logging, task_postrun, task_prerun
|
||||
from celery.signals import (
|
||||
after_task_publish,
|
||||
setup_logging,
|
||||
task_failure,
|
||||
task_internal_error,
|
||||
task_postrun,
|
||||
task_prerun,
|
||||
)
|
||||
from django.conf import settings
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.sentry import before_send
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
|
||||
# set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
|
||||
|
||||
@ -43,6 +53,18 @@ def task_postrun_hook(task_id, task, *args, retval=None, state=None, **kwargs):
|
||||
LOGGER.debug("Task finished", task_id=task_id, task_name=task.__name__, state=state)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@task_failure.connect
|
||||
@task_internal_error.connect
|
||||
def task_error_hook(task_id, exception: Exception, traceback, *args, **kwargs):
|
||||
"""Create system event for failed task"""
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
LOGGER.warning("Task failure", exception=exception)
|
||||
if before_send({}, {"exc_info": (None, exception, None)}) is not None:
|
||||
Event.new(EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception)).save()
|
||||
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
|
@ -59,14 +59,14 @@ class MetricsView(View):
|
||||
|
||||
|
||||
class LiveView(View):
|
||||
"""View for liveness probe, always returns Http 201"""
|
||||
"""View for liveness probe, always returns Http 204"""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
return HttpResponse(status=201)
|
||||
return HttpResponse(status=204)
|
||||
|
||||
|
||||
class ReadyView(View):
|
||||
"""View for readiness probe, always returns Http 201, unless sql or redis is down"""
|
||||
"""View for readiness probe, always returns Http 204, unless sql or redis is down"""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
try:
|
||||
@ -79,4 +79,4 @@ class ReadyView(View):
|
||||
redis_conn.ping()
|
||||
except RedisError:
|
||||
return HttpResponse(status=503)
|
||||
return HttpResponse(status=201)
|
||||
return HttpResponse(status=204)
|
||||
|
@ -529,7 +529,7 @@ for _app in INSTALLED_APPS:
|
||||
if "apps" in _app:
|
||||
_app = ".".join(_app.split(".")[:-2])
|
||||
try:
|
||||
app_settings = importlib.import_module("%s.settings" % _app)
|
||||
app_settings = importlib.import_module(f"{_app}.settings")
|
||||
INSTALLED_APPS.extend(getattr(app_settings, "INSTALLED_APPS", []))
|
||||
MIDDLEWARE.extend(getattr(app_settings, "MIDDLEWARE", []))
|
||||
AUTHENTICATION_BACKENDS.extend(getattr(app_settings, "AUTHENTICATION_BACKENDS", []))
|
||||
|
@ -1,12 +1,11 @@
|
||||
"""Source API Views"""
|
||||
from typing import Any
|
||||
|
||||
from django.http.response import Http404
|
||||
from django.utils.text import slugify
|
||||
from django_filters.filters import AllValuesMultipleFilter
|
||||
from django_filters.filterset import FilterSet
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.request import Request
|
||||
@ -19,6 +18,9 @@ from authentik.core.api.sources import SourceSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.events.monitored_tasks import TaskInfo
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||
|
||||
|
||||
class LDAPSourceSerializer(SourceSerializer):
|
||||
@ -95,19 +97,24 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: TaskSerializer(many=False),
|
||||
404: OpenApiResponse(description="Task not found"),
|
||||
200: TaskSerializer(many=True),
|
||||
}
|
||||
)
|
||||
@action(methods=["GET"], detail=True)
|
||||
@action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[])
|
||||
# pylint: disable=unused-argument
|
||||
def sync_status(self, request: Request, slug: str) -> Response:
|
||||
"""Get source's sync status"""
|
||||
source = self.get_object()
|
||||
task = TaskInfo.by_name(f"ldap_sync_{slugify(source.name)}")
|
||||
if not task:
|
||||
raise Http404
|
||||
return Response(TaskSerializer(task, many=False).data)
|
||||
results = []
|
||||
for sync_class in [
|
||||
UserLDAPSynchronizer,
|
||||
GroupLDAPSynchronizer,
|
||||
MembershipLDAPSynchronizer,
|
||||
]:
|
||||
task = TaskInfo.by_name(f"ldap_sync_{slugify(source.name)}-{sync_class.__name__}")
|
||||
if task:
|
||||
results.append(task)
|
||||
return Response(TaskSerializer(results, many=True).data)
|
||||
|
||||
|
||||
class LDAPPropertyMappingSerializer(PropertyMappingSerializer):
|
||||
|
@ -119,7 +119,7 @@ class LDAPPasswordChanger:
|
||||
return True
|
||||
|
||||
def ad_password_complexity(self, password: str, user: Optional[User] = None) -> bool:
|
||||
"""Check if password matches Active direcotry password policies
|
||||
"""Check if password matches Active directory password policies
|
||||
|
||||
https://docs.microsoft.com/en-us/windows/security/threat-protection/
|
||||
security-policy-settings/password-must-meet-complexity-requirements
|
||||
|
@ -4,7 +4,7 @@ from celery.schedules import crontab
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"sources_ldap_sync": {
|
||||
"task": "authentik.sources.ldap.tasks.ldap_sync_all",
|
||||
"schedule": crontab(minute="*/60"), # Run every hour
|
||||
"schedule": crontab(minute="*/120"), # Run every other hour
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
}
|
||||
}
|
||||
|
@ -11,8 +11,12 @@ from authentik.core.models import User
|
||||
from authentik.core.signals import password_changed
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
from authentik.sources.ldap.password import LDAPPasswordChanger
|
||||
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||
from authentik.sources.ldap.tasks import ldap_sync
|
||||
from authentik.stages.prompt.signals import password_validate
|
||||
|
||||
@ -21,8 +25,20 @@ from authentik.stages.prompt.signals import password_validate
|
||||
# pylint: disable=unused-argument
|
||||
def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
|
||||
"""Ensure that source is synced on save (if enabled)"""
|
||||
if instance.enabled:
|
||||
ldap_sync.delay(instance.pk)
|
||||
if not instance.enabled:
|
||||
return
|
||||
# Don't sync sources when they don't have any property mappings. This will only happen if:
|
||||
# - the user forgets to set them or
|
||||
# - the source is newly created, this is the first save event
|
||||
# and the mappings are created with an m2m event
|
||||
if not instance.property_mappings.exists() or not instance.property_mappings_group.exists():
|
||||
return
|
||||
for sync_class in [
|
||||
UserLDAPSynchronizer,
|
||||
GroupLDAPSynchronizer,
|
||||
MembershipLDAPSynchronizer,
|
||||
]:
|
||||
ldap_sync.delay(instance.pk, class_to_path(sync_class))
|
||||
|
||||
|
||||
@receiver(password_validate)
|
||||
@ -39,7 +55,7 @@ def ldap_password_validate(sender, password: str, plan_context: dict[str, Any],
|
||||
password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
|
||||
)
|
||||
if not passing:
|
||||
raise ValidationError(_("Password does not match Active Direcory Complexity."))
|
||||
raise ValidationError(_("Password does not match Active Directory Complexity."))
|
||||
|
||||
|
||||
@receiver(password_changed)
|
||||
|
@ -17,11 +17,18 @@ class BaseLDAPSynchronizer:
|
||||
|
||||
_source: LDAPSource
|
||||
_logger: BoundLogger
|
||||
_messages: list[str]
|
||||
|
||||
def __init__(self, source: LDAPSource):
|
||||
self._source = source
|
||||
self._messages = []
|
||||
self._logger = get_logger().bind(source=source, syncer=self.__class__.__name__)
|
||||
|
||||
@property
|
||||
def messages(self) -> list[str]:
|
||||
"""Get all UI messages"""
|
||||
return self._messages
|
||||
|
||||
@property
|
||||
def base_dn_users(self) -> str:
|
||||
"""Shortcut to get full base_dn for user lookups"""
|
||||
@ -36,6 +43,11 @@ class BaseLDAPSynchronizer:
|
||||
return f"{self._source.additional_group_dn},{self._source.base_dn}"
|
||||
return self._source.base_dn
|
||||
|
||||
def message(self, *args, **kwargs):
|
||||
"""Add message that is later added to the System Task and shown to the user"""
|
||||
self._messages.append(" ".join(args))
|
||||
self._logger.warning(*args, **kwargs)
|
||||
|
||||
def sync(self) -> int:
|
||||
"""Sync function, implemented in subclass"""
|
||||
raise NotImplementedError()
|
||||
|
@ -15,7 +15,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
def sync(self) -> int:
|
||||
"""Iterate over all LDAP Groups and create authentik_core.Group instances"""
|
||||
if not self._source.sync_groups:
|
||||
self._logger.warning("Group syncing is disabled for this Source")
|
||||
self.message("Group syncing is disabled for this Source")
|
||||
return -1
|
||||
groups = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_groups,
|
||||
@ -28,8 +28,8 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
attributes = group.get("attributes", {})
|
||||
group_dn = self._flatten(self._flatten(group.get("entryDN", group.get("dn"))))
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
self._logger.warning(
|
||||
"Cannot find uniqueness Field in attributes",
|
||||
self.message(
|
||||
f"Cannot find uniqueness field in attributes: '{group_dn}",
|
||||
attributes=attributes.keys(),
|
||||
dn=group_dn,
|
||||
)
|
||||
|
@ -39,11 +39,17 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
if not ak_group:
|
||||
continue
|
||||
|
||||
membership_mapping_attribute = LDAP_DISTINGUISHED_NAME
|
||||
if self._source.group_membership_field == "memberUid":
|
||||
# If memberships are based on the posixGroup's 'memberUid'
|
||||
# attribute we use the RDN instead of the FDN to lookup members.
|
||||
membership_mapping_attribute = LDAP_UNIQUENESS
|
||||
|
||||
users = User.objects.filter(
|
||||
Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members})
|
||||
Q(**{f"attributes__{membership_mapping_attribute}__in": members})
|
||||
| Q(
|
||||
**{
|
||||
f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True,
|
||||
f"attributes__{membership_mapping_attribute}__isnull": True,
|
||||
"ak_groups__in": [ak_group],
|
||||
}
|
||||
)
|
||||
@ -62,8 +68,8 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
# group_uniq might be a single string or an array with (hopefully) a single string
|
||||
if isinstance(group_uniq, list):
|
||||
if len(group_uniq) < 1:
|
||||
self._logger.warning(
|
||||
"Group does not have a uniqueness attribute.",
|
||||
self.message(
|
||||
f"Group does not have a uniqueness attribute: '{group_dn}'",
|
||||
group=group_dn,
|
||||
)
|
||||
return None
|
||||
@ -71,8 +77,8 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
if group_uniq not in self.group_cache:
|
||||
groups = Group.objects.filter(**{f"attributes__{LDAP_UNIQUENESS}": group_uniq})
|
||||
if not groups.exists():
|
||||
self._logger.warning(
|
||||
"Group does not exist in our DB yet, run sync_groups first.",
|
||||
self.message(
|
||||
f"Group does not exist in our DB yet, run sync_groups first: '{group_dn}'",
|
||||
group=group_dn,
|
||||
)
|
||||
return None
|
||||
|
@ -10,6 +10,7 @@ from pytz import UTC
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.vendor.ad import UserAccountControl
|
||||
|
||||
|
||||
class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
@ -18,7 +19,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
def sync(self) -> int:
|
||||
"""Iterate over all LDAP Users and create authentik_core.User instances"""
|
||||
if not self._source.sync_users:
|
||||
self._logger.warning("User syncing is disabled for this Source")
|
||||
self.message("User syncing is disabled for this Source")
|
||||
return -1
|
||||
users = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_users,
|
||||
@ -31,8 +32,8 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
attributes = user.get("attributes", {})
|
||||
user_dn = self._flatten(user.get("entryDN", user.get("dn")))
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
self._logger.warning(
|
||||
"Cannot find uniqueness Field in attributes",
|
||||
self.message(
|
||||
f"Cannot find uniqueness field in attributes: '{user_dn}",
|
||||
attributes=attributes.keys(),
|
||||
dn=user_dn,
|
||||
)
|
||||
@ -66,6 +67,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
pwd_last_set: datetime = attributes.get("pwdLastSet", datetime.now())
|
||||
pwd_last_set = pwd_last_set.replace(tzinfo=UTC)
|
||||
if created or pwd_last_set >= ak_user.password_change_date:
|
||||
self.message(f"'{ak_user.username}': Reset user's password")
|
||||
self._logger.debug(
|
||||
"Reset user's password",
|
||||
user=ak_user.username,
|
||||
@ -74,4 +76,8 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
)
|
||||
ak_user.set_unusable_password()
|
||||
ak_user.save()
|
||||
if "userAccountControl" in attributes:
|
||||
uac = UserAccountControl(attributes.get("userAccountControl"))
|
||||
ak_user.is_active = UserAccountControl.ACCOUNTDISABLE not in uac
|
||||
ak_user.save()
|
||||
return user_count
|
||||
|
0
authentik/sources/ldap/sync/vendor/__init__.py
vendored
Normal file
0
authentik/sources/ldap/sync/vendor/__init__.py
vendored
Normal file
32
authentik/sources/ldap/sync/vendor/ad.py
vendored
Normal file
32
authentik/sources/ldap/sync/vendor/ad.py
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
"""Active Directory specific"""
|
||||
from enum import IntFlag
|
||||
|
||||
|
||||
class UserAccountControl(IntFlag):
|
||||
"""UserAccountControl attribute for Active directory users"""
|
||||
|
||||
# https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity
|
||||
# /useraccountcontrol-manipulate-account-properties
|
||||
|
||||
SCRIPT = 1
|
||||
ACCOUNTDISABLE = 2
|
||||
HOMEDIR_REQUIRED = 8
|
||||
LOCKOUT = 16
|
||||
PASSWD_NOTREQD = 32
|
||||
PASSWD_CANT_CHANGE = 64
|
||||
ENCRYPTED_TEXT_PWD_ALLOWED = 128
|
||||
TEMP_DUPLICATE_ACCOUNT = 256
|
||||
NORMAL_ACCOUNT = 512
|
||||
INTERDOMAIN_TRUST_ACCOUNT = 2048
|
||||
WORKSTATION_TRUST_ACCOUNT = 4096
|
||||
SERVER_TRUST_ACCOUNT = 8192
|
||||
DONT_EXPIRE_PASSWORD = 65536
|
||||
MNS_LOGON_ACCOUNT = 131072
|
||||
SMARTCARD_REQUIRED = 262144
|
||||
TRUSTED_FOR_DELEGATION = 524288
|
||||
NOT_DELEGATED = 1048576
|
||||
USE_DES_KEY_ONLY = 2097152
|
||||
DONT_REQ_PREAUTH = 4194304
|
||||
PASSWORD_EXPIRED = 8388608
|
||||
TRUSTED_TO_AUTH_FOR_DELEGATION = 16777216
|
||||
PARTIAL_SECRETS_ACCOUNT = 67108864
|
@ -1,9 +1,12 @@
|
||||
"""LDAP Sync tasks"""
|
||||
from typing import Optional
|
||||
|
||||
from django.utils.text import slugify
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
|
||||
@ -17,11 +20,19 @@ LOGGER = get_logger()
|
||||
def ldap_sync_all():
|
||||
"""Sync all sources"""
|
||||
for source in LDAPSource.objects.filter(enabled=True):
|
||||
ldap_sync.delay(source.pk)
|
||||
for sync_class in [
|
||||
UserLDAPSynchronizer,
|
||||
GroupLDAPSynchronizer,
|
||||
MembershipLDAPSynchronizer,
|
||||
]:
|
||||
ldap_sync.delay(source.pk, class_to_path(sync_class))
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
def ldap_sync(self: MonitoredTask, source_pk: str):
|
||||
@CELERY_APP.task(
|
||||
bind=True, base=MonitoredTask, soft_time_limit=60 * 60 * 2, task_time_limit=60 * 60 * 2
|
||||
)
|
||||
# TODO: remove Optional[str] in 2021.10
|
||||
def ldap_sync(self: MonitoredTask, source_pk: str, sync_class: Optional[str] = None):
|
||||
"""Synchronization of an LDAP Source"""
|
||||
self.result_timeout_hours = 2
|
||||
try:
|
||||
@ -30,17 +41,15 @@ def ldap_sync(self: MonitoredTask, source_pk: str):
|
||||
# Because the source couldn't be found, we don't have a UID
|
||||
# to set the state with
|
||||
return
|
||||
self.set_uid(slugify(source.name))
|
||||
if not sync_class:
|
||||
return
|
||||
sync = path_to_class(sync_class)
|
||||
self.set_uid(f"{slugify(source.name)}-{sync.__name__}")
|
||||
try:
|
||||
messages = []
|
||||
for sync_class in [
|
||||
UserLDAPSynchronizer,
|
||||
GroupLDAPSynchronizer,
|
||||
MembershipLDAPSynchronizer,
|
||||
]:
|
||||
sync_inst = sync_class(source)
|
||||
count = sync_inst.sync()
|
||||
messages.append(f"Synced {count} objects from {sync_class.__name__}")
|
||||
sync_inst = sync(source)
|
||||
count = sync_inst.sync()
|
||||
messages = sync_inst.messages
|
||||
messages.append(f"Synced {count} objects.")
|
||||
self.set_status(
|
||||
TaskResult(
|
||||
TaskResultStatus.SUCCESSFUL,
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
||||
|
||||
from authentik.sources.ldap.sync.vendor.ad import UserAccountControl
|
||||
|
||||
|
||||
def mock_ad_connection(password: str) -> Connection:
|
||||
"""Create mock AD connection"""
|
||||
@ -54,6 +56,8 @@ def mock_ad_connection(password: str) -> Connection:
|
||||
"objectSid": "user0",
|
||||
"objectClass": "person",
|
||||
"distinguishedName": "cn=user0,ou=users,dc=goauthentik,dc=io",
|
||||
"userAccountControl": UserAccountControl.ACCOUNTDISABLE
|
||||
+ UserAccountControl.NORMAL_ACCOUNT,
|
||||
},
|
||||
)
|
||||
# User without SID
|
||||
|
@ -77,5 +77,24 @@ def mock_slapd_connection(password: str) -> Connection:
|
||||
"objectClass": "person",
|
||||
},
|
||||
)
|
||||
# Group with posixGroup and memberUid
|
||||
connection.strategy.add_entry(
|
||||
"cn=group-posix,ou=groups,dc=goauthentik,dc=io",
|
||||
{
|
||||
"cn": "group-posix",
|
||||
"objectClass": "posixGroup",
|
||||
"memberUid": ["user-posix"],
|
||||
},
|
||||
)
|
||||
# User with posixAccount
|
||||
connection.strategy.add_entry(
|
||||
"cn=user-posix,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"userPassword": password,
|
||||
"uid": "user-posix",
|
||||
"cn": "user-posix",
|
||||
"objectClass": "posixAccount",
|
||||
},
|
||||
)
|
||||
connection.bind()
|
||||
return connection
|
||||
|
@ -72,7 +72,8 @@ class LDAPSyncTests(TestCase):
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
user_sync = UserLDAPSynchronizer(self.source)
|
||||
user_sync.sync()
|
||||
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
||||
user = User.objects.filter(username="user0_sn").first()
|
||||
self.assertFalse(user.is_active)
|
||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||
|
||||
def test_sync_users_openldap(self):
|
||||
@ -136,6 +137,34 @@ class LDAPSyncTests(TestCase):
|
||||
group = Group.objects.filter(name="group1")
|
||||
self.assertTrue(group.exists())
|
||||
|
||||
def test_sync_groups_openldap_posix_group(self):
|
||||
"""Test posix group sync"""
|
||||
self.source.object_uniqueness_field = "cn"
|
||||
self.source.group_membership_field = "memberUid"
|
||||
self.source.user_object_filter = "(objectClass=posixAccount)"
|
||||
self.source.group_object_filter = "(objectClass=posixGroup)"
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
|
||||
)
|
||||
)
|
||||
self.source.property_mappings_group.set(
|
||||
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn")
|
||||
)
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
user_sync = UserLDAPSynchronizer(self.source)
|
||||
user_sync.sync()
|
||||
group_sync = GroupLDAPSynchronizer(self.source)
|
||||
group_sync.sync()
|
||||
membership_sync = MembershipLDAPSynchronizer(self.source)
|
||||
membership_sync.sync()
|
||||
# Test if membership mapping based on memberUid works.
|
||||
posix_group = Group.objects.filter(name="group-posix").first()
|
||||
self.assertTrue(posix_group.users.filter(name="user-posix").exists())
|
||||
|
||||
def test_tasks_ad(self):
|
||||
"""Test Scheduled tasks"""
|
||||
self.source.property_mappings.set(
|
||||
|
@ -46,9 +46,9 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||
type = SerializerMethodField()
|
||||
|
||||
@extend_schema_field(SourceTypeSerializer)
|
||||
def get_type(self, instace: OAuthSource) -> SourceTypeSerializer:
|
||||
def get_type(self, instance: OAuthSource) -> SourceTypeSerializer:
|
||||
"""Get source's type configuration"""
|
||||
return SourceTypeSerializer(instace.type).data
|
||||
return SourceTypeSerializer(instance.type).data
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
provider_type = MANAGER.find_type(attrs.get("provider_type", ""))
|
||||
|
@ -14,7 +14,7 @@ class Migration(migrations.Migration):
|
||||
model_name="oauthsource",
|
||||
name="access_token_url",
|
||||
field=models.CharField(
|
||||
help_text="URL used by authentik to retrive tokens.",
|
||||
help_text="URL used by authentik to retrieve tokens.",
|
||||
max_length=255,
|
||||
verbose_name="Access Token URL",
|
||||
),
|
||||
|
@ -15,7 +15,7 @@ class Migration(migrations.Migration):
|
||||
name="access_token_url",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="URL used by authentik to retrive tokens.",
|
||||
help_text="URL used by authentik to retrieve tokens.",
|
||||
max_length=255,
|
||||
verbose_name="Access Token URL",
|
||||
),
|
||||
|
@ -39,7 +39,7 @@ class Migration(migrations.Migration):
|
||||
model_name="oauthsource",
|
||||
name="access_token_url",
|
||||
field=models.CharField(
|
||||
help_text="URL used by authentik to retrive tokens.",
|
||||
help_text="URL used by authentik to retrieve tokens.",
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Access Token URL",
|
||||
|
@ -37,7 +37,7 @@ class OAuthSource(Source):
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name=_("Access Token URL"),
|
||||
help_text=_("URL used by authentik to retrive tokens."),
|
||||
help_text=_("URL used by authentik to retrieve tokens."),
|
||||
)
|
||||
profile_url = models.CharField(
|
||||
max_length=255,
|
||||
|
@ -24,7 +24,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name="plexsource",
|
||||
name="plex_token",
|
||||
field=models.TextField(default="", help_text="Plex token used to check firends"),
|
||||
field=models.TextField(default="", help_text="Plex token used to check friends"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="plexsource",
|
||||
|
@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name="plexsource",
|
||||
name="plex_token",
|
||||
field=models.TextField(help_text="Plex token used to check firends"),
|
||||
field=models.TextField(help_text="Plex token used to check friends"),
|
||||
),
|
||||
]
|
||||
|
@ -50,7 +50,7 @@ class PlexSource(Source):
|
||||
default=True,
|
||||
help_text=_("Allow friends to authenticate, even if you don't share a server."),
|
||||
)
|
||||
plex_token = models.TextField(help_text=_("Plex token used to check firends"))
|
||||
plex_token = models.TextField(help_text=_("Plex token used to check friends"))
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
|
@ -54,7 +54,7 @@ class CaptchaChallengeResponse(ChallengeResponse):
|
||||
|
||||
|
||||
class CaptchaStageView(ChallengeStageView):
|
||||
"""Simple captcha checker, logic is handeled in django-captcha module"""
|
||||
"""Simple captcha checker, logic is handled in django-captcha module"""
|
||||
|
||||
response_class = CaptchaChallengeResponse
|
||||
|
||||
|
@ -43,6 +43,7 @@ class EmailStageSerializer(StageSerializer):
|
||||
"token_expiry",
|
||||
"subject",
|
||||
"template",
|
||||
"activate_user_on_success",
|
||||
]
|
||||
extra_kwargs = {"password": {"write_only": True}}
|
||||
|
||||
@ -65,6 +66,7 @@ class EmailStageViewSet(UsedByMixin, ModelViewSet):
|
||||
"token_expiry",
|
||||
"subject",
|
||||
"template",
|
||||
"activate_user_on_success",
|
||||
]
|
||||
ordering = ["name"]
|
||||
|
||||
|
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.7 on 2021-10-04 16:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_email", "0003_auto_20210404_1054"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="emailstage",
|
||||
name="activate_user_on_success",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Activate users upon completion of stage."
|
||||
),
|
||||
),
|
||||
]
|
@ -71,6 +71,10 @@ class EmailStage(Stage):
|
||||
timeout = models.IntegerField(default=10)
|
||||
from_address = models.EmailField(default="system@authentik.local")
|
||||
|
||||
activate_user_on_success = models.BooleanField(
|
||||
default=False, help_text=_("Activate users upon completion of stage.")
|
||||
)
|
||||
|
||||
token_expiry = models.IntegerField(
|
||||
default=30, help_text=_("Time in minutes the token sent is valid.")
|
||||
)
|
||||
|
@ -3,9 +3,9 @@ from datetime import timedelta
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.fields import CharField
|
||||
@ -22,7 +22,7 @@ from authentik.stages.email.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
LOGGER = get_logger()
|
||||
QS_KEY_TOKEN = "token" # nosec
|
||||
QS_KEY_TOKEN = "etoken" # nosec
|
||||
PLAN_CONTEXT_EMAIL_SENT = "email_sent"
|
||||
|
||||
|
||||
@ -65,7 +65,7 @@ class EmailStageView(ChallengeStageView):
|
||||
) # + 1 because django timesince always rounds down
|
||||
token_filters = {
|
||||
"user": pending_user,
|
||||
"identifier": f"ak-email-stage-{current_stage.name}-{pending_user}",
|
||||
"identifier": slugify(f"ak-email-stage-{current_stage.name}-{pending_user}"),
|
||||
}
|
||||
# Don't check for validity here, we only care if the token exists
|
||||
tokens = Token.objects.filter(**token_filters)
|
||||
@ -99,10 +99,16 @@ class EmailStageView(ChallengeStageView):
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
# Check if the user came back from the email link to verify
|
||||
if QS_KEY_TOKEN in request.session.get(SESSION_KEY_GET, {}):
|
||||
token = get_object_or_404(Token, key=request.session[SESSION_KEY_GET][QS_KEY_TOKEN])
|
||||
tokens = Token.filter_not_expired(key=request.session[SESSION_KEY_GET][QS_KEY_TOKEN])
|
||||
if not tokens.exists():
|
||||
return self.executor.stage_invalid(_("Invalid token"))
|
||||
token = tokens.first()
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user
|
||||
token.delete()
|
||||
messages.success(request, _("Successfully verified Email."))
|
||||
if self.executor.current_stage.activate_user_on_success:
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER].is_active = True
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER].save()
|
||||
return self.executor.stage_ok()
|
||||
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
|
||||
LOGGER.debug("No pending user")
|
||||
@ -118,7 +124,7 @@ class EmailStageView(ChallengeStageView):
|
||||
challenge = EmailChallenge(
|
||||
data={
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"title": "Email sent.",
|
||||
"title": _("Email sent."),
|
||||
}
|
||||
)
|
||||
return challenge
|
||||
|
@ -31,6 +31,7 @@ class TestEmailStage(APITestCase):
|
||||
)
|
||||
self.stage = EmailStage.objects.create(
|
||||
name="email",
|
||||
activate_user_on_success=True,
|
||||
)
|
||||
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
|
||||
|
||||
@ -84,6 +85,8 @@ class TestEmailStage(APITestCase):
|
||||
"""Test with token"""
|
||||
# Make sure token exists
|
||||
self.test_pending_user()
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
@ -125,3 +128,4 @@ class TestEmailStage(APITestCase):
|
||||
session = self.client.session
|
||||
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
||||
self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER], self.user)
|
||||
self.assertTrue(plan.context[PLAN_CONTEXT_PENDING_USER].is_active)
|
||||
|
@ -15,7 +15,8 @@ from authentik.stages.invitation.signals import invitation_used
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
LOGGER = get_logger()
|
||||
INVITATION_TOKEN_KEY = "token" # nosec
|
||||
INVITATION_TOKEN_KEY_CONTEXT = "token" # nosec
|
||||
INVITATION_TOKEN_KEY = "itoken" # nosec
|
||||
INVITATION_IN_EFFECT = "invitation_in_effect"
|
||||
INVITATION = "invitation"
|
||||
|
||||
@ -29,10 +30,14 @@ class InvitationStageView(StageView):
|
||||
|
||||
def get_token(self) -> Optional[str]:
|
||||
"""Get token from saved get-arguments or prompt_data"""
|
||||
# Check for ?token= and ?itoken=
|
||||
if INVITATION_TOKEN_KEY in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY]
|
||||
if INVITATION_TOKEN_KEY in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY]
|
||||
if INVITATION_TOKEN_KEY_CONTEXT in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY_CONTEXT]
|
||||
# Check for {'token': ''} in the context
|
||||
if INVITATION_TOKEN_KEY_CONTEXT in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY_CONTEXT]
|
||||
return None
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user