mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 15:12:13 +02:00
Compare commits
209 Commits
auto-categ
...
enforce-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc794d927e | ||
|
|
01b91e9dd1 | ||
|
|
6efb3f5fb0 | ||
|
|
4b24a7ddc1 | ||
|
|
b6b423bee9 | ||
|
|
860a43e56a | ||
|
|
c1cbd0623c | ||
|
|
0893031cab | ||
|
|
89868276da | ||
|
|
dd40f60c88 | ||
|
|
73be48c179 | ||
|
|
fc27dfeeb7 | ||
|
|
dbbfb3cf19 | ||
|
|
6d7249ea56 | ||
|
|
a07e820bce | ||
|
|
31186baf25 | ||
|
|
024e6c1961 | ||
|
|
1244a40ffb | ||
|
|
dcfe722f5c | ||
|
|
6b1171aac8 | ||
|
|
b2d5519611 | ||
|
|
1620a96cd4 | ||
|
|
a42fc4b741 | ||
|
|
9b822ce0fd | ||
|
|
05c30af790 | ||
|
|
6683d9943c | ||
|
|
17ef75c19f | ||
|
|
d8428bf59a | ||
|
|
3ef06094b5 | ||
|
|
6b22487406 | ||
|
|
0fa412e782 | ||
|
|
334c0175f9 | ||
|
|
3c2f39559f | ||
|
|
d05ad4403b | ||
|
|
10866f9dfc | ||
|
|
97f0c6475d | ||
|
|
0f6cb9183e | ||
|
|
499c1b6fab | ||
|
|
362d67ca6e | ||
|
|
abe944b8c9 | ||
|
|
bba9643864 | ||
|
|
467af902f1 | ||
|
|
e28a8aacc7 | ||
|
|
af0444b0dd | ||
|
|
8fcf60ecce | ||
|
|
10ebbcfd61 | ||
|
|
6a1bde1fd8 | ||
|
|
6d5092a394 | ||
|
|
0a3763b82b | ||
|
|
6a80490fdb | ||
|
|
74266a1e3d | ||
|
|
29a9e31143 | ||
|
|
e2df658d88 | ||
|
|
302898a00a | ||
|
|
18663bffa5 | ||
|
|
f46159bb3a | ||
|
|
ea19094c46 | ||
|
|
cc3ebb29ad | ||
|
|
bbc9943bb3 | ||
|
|
f9d3e91106 | ||
|
|
7a6f1f3165 | ||
|
|
c78b5c36bb | ||
|
|
4ac6825e9f | ||
|
|
41162d3ad2 | ||
|
|
c1cfeaf4b5 | ||
|
|
fe7a8894d3 | ||
|
|
96eb8dda0f | ||
|
|
d0ef8a8b8e | ||
|
|
1474c65e11 | ||
|
|
324a6de47c | ||
|
|
bee733b484 | ||
|
|
5ccd66ddca | ||
|
|
b8e15ad0d0 | ||
|
|
39f8969f51 | ||
|
|
45ee4af451 | ||
|
|
c30d1a478d | ||
|
|
f914af70f1 | ||
|
|
a46c5e5d89 | ||
|
|
1ebd7cf0f4 | ||
|
|
5a7a76f9b3 | ||
|
|
f607378487 | ||
|
|
e114a68505 | ||
|
|
ebe028f3c9 | ||
|
|
eb60276846 | ||
|
|
952a0f796d | ||
|
|
b7205ff167 | ||
|
|
5379c509d6 | ||
|
|
63119df516 | ||
|
|
cd53ab5eba | ||
|
|
5aa7a1c62d | ||
|
|
092c8a3db4 | ||
|
|
9d8427b1b4 | ||
|
|
92556ca783 | ||
|
|
d5c2aedc8e | ||
|
|
02304081ed | ||
|
|
d60b6faa61 | ||
|
|
b12d1ed410 | ||
|
|
1276d87d69 | ||
|
|
0ba097ad95 | ||
|
|
3ff7332742 | ||
|
|
72f0f98706 | ||
|
|
873a47f9a2 | ||
|
|
382bd324c2 | ||
|
|
71e57611d0 | ||
|
|
04a8f02b2a | ||
|
|
2250cea934 | ||
|
|
b6d21bad71 | ||
|
|
2bb86f6f12 | ||
|
|
c75ff13770 | ||
|
|
874a20b908 | ||
|
|
ea7cbafefb | ||
|
|
46b889eab1 | ||
|
|
c0744d6cf3 | ||
|
|
9bae5caee4 | ||
|
|
5756b189bb | ||
|
|
af2f86e030 | ||
|
|
be90f63f43 | ||
|
|
7357b9cc58 | ||
|
|
ec467bc2d1 | ||
|
|
d140e4fdd3 | ||
|
|
75ead31448 | ||
|
|
713bb04c21 | ||
|
|
c14a029b23 | ||
|
|
6092a26450 | ||
|
|
6fba028404 | ||
|
|
678e1d87ba | ||
|
|
f1a1f327cd | ||
|
|
d94117a8e3 | ||
|
|
fa32567230 | ||
|
|
59da20e81c | ||
|
|
1fb71371cb | ||
|
|
a7b42499b5 | ||
|
|
7e8d2ad591 | ||
|
|
dfa00cd1f8 | ||
|
|
c93872cb93 | ||
|
|
1137f945f4 | ||
|
|
08ccd3c25c | ||
|
|
d6bf2131ee | ||
|
|
997da339a0 | ||
|
|
d5f730a0d0 | ||
|
|
ce0d60bc83 | ||
|
|
3f53e41236 | ||
|
|
d7cf0179f5 | ||
|
|
3717df1a72 | ||
|
|
0f972f2dc6 | ||
|
|
698c1ce817 | ||
|
|
04d7f6cec8 | ||
|
|
1f3fdbb7e3 | ||
|
|
0490f53eaa | ||
|
|
46ef933d33 | ||
|
|
d211ea1560 | ||
|
|
88dd0e84c0 | ||
|
|
fa300d5d10 | ||
|
|
08039b8249 | ||
|
|
e786e8a17d | ||
|
|
02b8b52e09 | ||
|
|
2cae8160c3 | ||
|
|
f6dc7acfdb | ||
|
|
5739d26bbb | ||
|
|
2145d9221a | ||
|
|
8bcdf162a1 | ||
|
|
171305ca47 | ||
|
|
928e5b1a90 | ||
|
|
9621082f06 | ||
|
|
40b6f4a115 | ||
|
|
6f9db3fccf | ||
|
|
7e3c57a98c | ||
|
|
2b135805a1 | ||
|
|
3dd0857a64 | ||
|
|
cb6cbcc412 | ||
|
|
12c8bca8bf | ||
|
|
a37162aebd | ||
|
|
a6e7d2c02f | ||
|
|
b8dee0c0c3 | ||
|
|
5b9f97deb4 | ||
|
|
1c1e9af22b | ||
|
|
3d5f975fbb | ||
|
|
3dbecdbd30 | ||
|
|
a8e765257e | ||
|
|
e002243a8a | ||
|
|
c18f6d2f21 | ||
|
|
7d41e4c797 | ||
|
|
0b0a030e35 | ||
|
|
18891b72e1 | ||
|
|
9a25fd4a3f | ||
|
|
d666019cd6 | ||
|
|
06ed7a428c | ||
|
|
d7beceb2c0 | ||
|
|
a804f7013b | ||
|
|
1939ed8610 | ||
|
|
70e275d24b | ||
|
|
6a671ac5b0 | ||
|
|
9e1ca6def5 | ||
|
|
91b75a2c47 | ||
|
|
f61445a342 | ||
|
|
eccc786eb2 | ||
|
|
dbf90c49a7 | ||
|
|
e9469faf89 | ||
|
|
2a6592737b | ||
|
|
3cf18020bc | ||
|
|
90d4555935 | ||
|
|
a0278a6818 | ||
|
|
ee9d12052e | ||
|
|
28ac595716 | ||
|
|
fc9d534cc5 | ||
|
|
74bb0904b4 | ||
|
|
e9c2e10828 | ||
|
|
d4d6c466a9 | ||
|
|
64965e3750 |
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
@@ -21,12 +21,12 @@ runs:
|
||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v5
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
- name: Install Python deps
|
||||
@@ -35,7 +35,7 @@ runs:
|
||||
run: uv sync --all-extras --dev --frozen
|
||||
- name: Setup node
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v4
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
@@ -43,7 +43,7 @@ runs:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Setup go
|
||||
if: ${{ contains(inputs.dependencies, 'go') }}
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Setup docker cache
|
||||
|
||||
17
.github/actions/setup/docker-compose.yml
vendored
17
.github/actions/setup/docker-compose.yml
vendored
@@ -16,7 +16,24 @@ services:
|
||||
ports:
|
||||
- 6379:6379
|
||||
restart: always
|
||||
s3:
|
||||
container_name: s3
|
||||
image: docker.io/zenko/cloudserver
|
||||
environment:
|
||||
REMOTE_MANAGEMENT_DISABLE: "1"
|
||||
SCALITY_ACCESS_KEY_ID: accessKey1
|
||||
SCALITY_SECRET_ACCESS_KEY: secretKey1
|
||||
ports:
|
||||
- 8020:8000
|
||||
volumes:
|
||||
- s3-data:/usr/src/app/localData
|
||||
- s3-metadata:/usr/scr/app/localMetadata
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
driver: local
|
||||
s3-data:
|
||||
driver: local
|
||||
s3-metadata:
|
||||
driver: local
|
||||
|
||||
119
.github/dependabot.yml
vendored
119
.github/dependabot.yml
vendored
@@ -1,5 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
#region Github Actions
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directories:
|
||||
- /
|
||||
@@ -18,6 +20,11 @@ updates:
|
||||
prefix: "ci:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golang
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
@@ -28,16 +35,16 @@ updates:
|
||||
prefix: "core:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
#region Web
|
||||
|
||||
- package-ecosystem: npm
|
||||
directories:
|
||||
- "/"
|
||||
- "/web"
|
||||
- "/web/packages/sfe"
|
||||
- "/web/packages/core"
|
||||
- "/packages/esbuild-plugin-live-reload"
|
||||
- "/packages/prettier-config"
|
||||
- "/packages/tsconfig"
|
||||
- "/packages/docusaurus-config"
|
||||
- "/packages/eslint-config"
|
||||
- "/web/packages/*"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
@@ -50,7 +57,6 @@ updates:
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
- "@spotlightjs/*"
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
@@ -66,10 +72,12 @@ updates:
|
||||
patterns:
|
||||
- "@storybook/*"
|
||||
- "*storybook*"
|
||||
esbuild:
|
||||
bundler:
|
||||
patterns:
|
||||
- "@esbuild/*"
|
||||
- "esbuild*"
|
||||
- "@vitest/*"
|
||||
- "vitest"
|
||||
rollup:
|
||||
patterns:
|
||||
- "@rollup/*"
|
||||
@@ -79,9 +87,6 @@ updates:
|
||||
patterns:
|
||||
- "@swc/*"
|
||||
- "swc-*"
|
||||
wdio:
|
||||
patterns:
|
||||
- "@wdio/*"
|
||||
goauthentik:
|
||||
patterns:
|
||||
- "@goauthentik/*"
|
||||
@@ -91,6 +96,74 @@ updates:
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
|
||||
#endregion
|
||||
|
||||
#region NPM Packages
|
||||
|
||||
- package-ecosystem: npm
|
||||
directories:
|
||||
- "/packages/esbuild-plugin-live-reload"
|
||||
- "/packages/prettier-config"
|
||||
- "/packages/tsconfig"
|
||||
- "/packages/docusaurus-config"
|
||||
- "/packages/eslint-config"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
labels:
|
||||
- dependencies
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "core, web:"
|
||||
groups:
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
- "babel-*"
|
||||
eslint:
|
||||
patterns:
|
||||
- "@eslint/*"
|
||||
- "@typescript-eslint/*"
|
||||
- "eslint-*"
|
||||
- "eslint"
|
||||
- "typescript-eslint"
|
||||
storybook:
|
||||
patterns:
|
||||
- "@storybook/*"
|
||||
- "*storybook*"
|
||||
bundler:
|
||||
patterns:
|
||||
- "@esbuild/*"
|
||||
- "esbuild*"
|
||||
- "@vitest/*"
|
||||
- "vitest"
|
||||
rollup:
|
||||
patterns:
|
||||
- "@rollup/*"
|
||||
- "rollup-*"
|
||||
- "rollup*"
|
||||
swc:
|
||||
patterns:
|
||||
- "@swc/*"
|
||||
- "swc-*"
|
||||
goauthentik:
|
||||
patterns:
|
||||
- "@goauthentik/*"
|
||||
react:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
|
||||
#endregion
|
||||
|
||||
# #region Documentation
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/website"
|
||||
schedule:
|
||||
@@ -105,6 +178,7 @@ updates:
|
||||
docusaurus:
|
||||
patterns:
|
||||
- "@docusaurus/*"
|
||||
- "@goauthentik/docusaurus-config"
|
||||
build:
|
||||
patterns:
|
||||
- "@swc/*"
|
||||
@@ -113,7 +187,9 @@ updates:
|
||||
- "@rspack/binding*"
|
||||
goauthentik:
|
||||
patterns:
|
||||
- "@goauthentik/*"
|
||||
- "@goauthentik/eslint-config"
|
||||
- "@goauthentik/prettier-config"
|
||||
- "@goauthentik/tsconfig"
|
||||
eslint:
|
||||
patterns:
|
||||
- "@eslint/*"
|
||||
@@ -121,6 +197,11 @@ updates:
|
||||
- "eslint-*"
|
||||
- "eslint"
|
||||
- "typescript-eslint"
|
||||
|
||||
#endregion
|
||||
|
||||
# AWS Lifecycle
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/lifecycle/aws"
|
||||
schedule:
|
||||
@@ -131,6 +212,11 @@ updates:
|
||||
prefix: "lifecycle/aws:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
#region Python
|
||||
|
||||
- package-ecosystem: uv
|
||||
directory: "/"
|
||||
schedule:
|
||||
@@ -141,6 +227,11 @@ updates:
|
||||
prefix: "core:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
#region Docker
|
||||
|
||||
- package-ecosystem: docker
|
||||
directories:
|
||||
- /
|
||||
@@ -166,3 +257,5 @@ updates:
|
||||
prefix: "core:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
1
.github/transifex.yml
vendored
1
.github/transifex.yml
vendored
@@ -1,3 +1,4 @@
|
||||
---
|
||||
git:
|
||||
filters:
|
||||
- filter_type: file
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
mkdir -p ./gen-go-api
|
||||
- name: Setup node
|
||||
if: ${{ !inputs.release }}
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
|
||||
4
.github/workflows/_reusable-docker-build.yml
vendored
4
.github/workflows/_reusable-docker-build.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
tags: ${{ steps.ev.outputs.imageTagsJSON }}
|
||||
shouldPush: ${{ steps.ev.outputs.shouldPush }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
matrix:
|
||||
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
|
||||
8
.github/workflows/api-ts-publish.yml
vendored
8
.github/workflows/api-ts-publish.yml
vendored
@@ -18,14 +18,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
run: |
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
10
.github/workflows/ci-api-docs.yml
vendored
10
.github/workflows/ci-api-docs.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
command:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Install Dependencies
|
||||
working-directory: website/
|
||||
run: npm ci
|
||||
@@ -32,8 +32,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
@@ -66,12 +66,12 @@ jobs:
|
||||
- lint
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
|
||||
4
.github/workflows/ci-aws-cfn.yml
vendored
4
.github/workflows/ci-aws-cfn.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
check-changes-applied:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: lifecycle/aws/package.json
|
||||
cache: "npm"
|
||||
|
||||
2
.github/workflows/ci-docs-source.yml
vendored
2
.github/workflows/ci-docs-source.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: generate docs
|
||||
|
||||
12
.github/workflows/ci-docs.yml
vendored
12
.github/workflows/ci-docs.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
command:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Install dependencies
|
||||
working-directory: website/
|
||||
run: npm ci
|
||||
@@ -32,8 +32,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
@@ -48,8 +48,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
|
||||
2
.github/workflows/ci-main-daily.yml
vendored
2
.github/workflows/ci-main-daily.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
- version-2025-4
|
||||
- version-2025-2
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- run: |
|
||||
current="$(pwd)"
|
||||
dir="/tmp/authentik/${{ matrix.version }}"
|
||||
|
||||
18
.github/workflows/ci-main.yml
vendored
18
.github/workflows/ci-main.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
- mypy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run job
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
test-migrations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run migrations
|
||||
@@ -71,14 +71,12 @@ jobs:
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: checkout stable
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
# Copy current, latest config to local
|
||||
cp authentik/lib/default.yml local.env.yml
|
||||
cp -R .github ..
|
||||
cp -R scripts ..
|
||||
# Previous stable tag
|
||||
@@ -89,7 +87,7 @@ jobs:
|
||||
prev_stable=$current_version_family
|
||||
fi
|
||||
echo "::notice::Checking out ${prev_stable} as stable version..."
|
||||
git checkout $(prev_stable)
|
||||
git checkout ${prev_stable}
|
||||
rm -rf .github/ scripts/
|
||||
mv ../.github ../scripts .
|
||||
- name: Setup authentik env (stable)
|
||||
@@ -138,7 +136,7 @@ jobs:
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -158,7 +156,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Create k8s Kind Cluster
|
||||
@@ -196,7 +194,7 @@ jobs:
|
||||
- name: flows
|
||||
glob: tests/e2e/test_flows*
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup e2e env (chrome, etc)
|
||||
@@ -262,7 +260,7 @@ jobs:
|
||||
pull-requests: write
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: prepare variables
|
||||
|
||||
18
.github/workflows/ci-outpost.yml
vendored
18
.github/workflows/ci-outpost.yml
vendored
@@ -21,8 +21,8 @@ jobs:
|
||||
lint-golint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Prepare and generate API
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 # v8
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v8
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout 5000s --verbose
|
||||
@@ -42,8 +42,8 @@ jobs:
|
||||
test-unittest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Setup authentik env
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
@@ -145,13 +145,13 @@ jobs:
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
|
||||
12
.github/workflows/ci-web.yml
vendored
12
.github/workflows/ci-web.yml
vendored
@@ -31,8 +31,8 @@ jobs:
|
||||
- command: lit-analyse
|
||||
project: web
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: ${{ matrix.project }}/package.json
|
||||
cache: "npm"
|
||||
@@ -48,8 +48,8 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
@@ -76,8 +76,8 @@ jobs:
|
||||
- ci-web-mark
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
|
||||
6
.github/workflows/gen-image-compress.yml
vendored
6
.github/workflows/gen-image-compress.yml
vendored
@@ -29,11 +29,11 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Compress images
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
|
||||
id: cpr
|
||||
with:
|
||||
|
||||
@@ -16,17 +16,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- run: uv run ak update_webauthn_mds
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
4
.github/workflows/gh-cherry-pick.yml
vendored
4
.github/workflows/gh-cherry-pick.yml
vendored
@@ -10,14 +10,14 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
if: ${{ env.GH_APP_ID != '' }}
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
env:
|
||||
GH_APP_ID: ${{ secrets.GH_APP_ID }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
if: ${{ steps.app-token.outcome != 'skipped' }}
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
|
||||
2
.github/workflows/gh-ghcr-retention.yml
vendored
2
.github/workflows/gh-ghcr-retention.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
15
.github/workflows/packages-npm-publish.yml
vendored
15
.github/workflows/packages-npm-publish.yml
vendored
@@ -5,10 +5,10 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- packages/docusaurus-config/**
|
||||
- packages/tsconfig/**
|
||||
- packages/eslint-config/**
|
||||
- packages/prettier-config/**
|
||||
- packages/tsconfig/**
|
||||
- packages/docusaurus-config/**
|
||||
- packages/esbuild-plugin-live-reload/**
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,16 +24,17 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package:
|
||||
- packages/docusaurus-config
|
||||
# The order of the `*config` packages should not be changed, as they depend on each other.
|
||||
- packages/tsconfig
|
||||
- packages/eslint-config
|
||||
- packages/prettier-config
|
||||
- packages/tsconfig
|
||||
- packages/docusaurus-config
|
||||
- packages/esbuild-plugin-live-reload
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: ${{ matrix.package }}/package.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -43,6 +44,8 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
${{ matrix.package }}/package.json
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
- name: Publish package
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ${{ matrix.package }}
|
||||
|
||||
2
.github/workflows/qa-codeql.yml
vendored
2
.github/workflows/qa-codeql.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
language: ["go", "javascript", "python"]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Initialize CodeQL
|
||||
|
||||
2
.github/workflows/qa-semgrep.yml
vendored
2
.github/workflows/qa-semgrep.yml
vendored
@@ -26,5 +26,5 @@ jobs:
|
||||
image: semgrep/semgrep
|
||||
if: (github.actor != 'dependabot[bot]')
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- run: semgrep ci
|
||||
|
||||
10
.github/workflows/release-branch-off.yml
vendored
10
.github/workflows/release-branch-off.yml
vendored
@@ -29,12 +29,12 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: main
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -57,12 +57,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.next_version }}.0-rc1"
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: release-bump-${{ inputs.next_version }}
|
||||
|
||||
2
.github/workflows/release-next-branch.yml
vendored
2
.github/workflows/release-next-branch.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: internal-production
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: main
|
||||
- run: |
|
||||
|
||||
22
.github/workflows/release-publish.yml
vendored
22
.github/workflows/release-publish.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
@@ -83,8 +83,8 @@ jobs:
|
||||
- radius
|
||||
- rac
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Set up QEMU
|
||||
@@ -146,11 +146,11 @@ jobs:
|
||||
goos: [linux, darwin]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
export CGO_ENABLED=0
|
||||
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
||||
- name: Upload binaries to release
|
||||
uses: svenstaro/upload-release-action@81c65b7cd4de9b2570615ce3aad67a41de5b1a13 # v2
|
||||
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
@@ -186,8 +186,8 @@ jobs:
|
||||
AWS_REGION: eu-central-1
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: aws-actions/configure-aws-credentials@00943011d9042930efac3dcd3a170e4273319bc8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5
|
||||
with:
|
||||
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
- build-outpost-binary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Run test suite in final docker images
|
||||
run: |
|
||||
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
|
||||
@@ -218,7 +218,7 @@ jobs:
|
||||
- build-outpost-binary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
|
||||
20
.github/workflows/release-tag.yml
vendored
20
.github/workflows/release-tag.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
tag_name: "version/${{ inputs.version }}"
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/helm"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/version"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
|
||||
4
.github/workflows/repo-stale.yml
vendored
4
.github/workflows/repo-stale.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
|
||||
with:
|
||||
repo-token: ${{ steps.generate_token.outputs.token }}
|
||||
days-before-stale: 60
|
||||
|
||||
@@ -21,15 +21,15 @@ jobs:
|
||||
steps:
|
||||
- id: generate_token
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
make web-check-compile
|
||||
- name: Create Pull Request
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: extract-compile-backend-translation
|
||||
|
||||
41
.github/workflows/translation-rename.yml
vendored
41
.github/workflows/translation-rename.yml
vendored
@@ -1,41 +0,0 @@
|
||||
---
|
||||
# Rename transifex pull requests to have a correct naming
|
||||
# Also enables auto squash-merge
|
||||
name: Translation - Auto-rename Transifex PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
# Permission to rename PR
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
rename_pr:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Get current title
|
||||
id: title
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
run: |
|
||||
title=$(gh pr view ${{ github.event.pull_request.number }} --json "title" -q ".title")
|
||||
echo "title=${title}" >> "$GITHUB_OUTPUT"
|
||||
- name: Rename
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
run: |
|
||||
gh pr edit ${{ github.event.pull_request.number }} -t "translate: ${{ steps.title.outputs.title }}" --add-label dependencies
|
||||
- uses: peter-evans/enable-pull-request-automerge@a660677d5469627102a1c1e11409dd063606628d # v3
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
pull-request-number: ${{ github.event.pull_request.number }}
|
||||
merge-method: squash
|
||||
@@ -26,6 +26,10 @@ website/api/reference
|
||||
node_modules
|
||||
coverage
|
||||
|
||||
## Vendored files
|
||||
vendored
|
||||
*.min.js
|
||||
|
||||
## Configs
|
||||
*.log
|
||||
*.yaml
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -11,6 +11,9 @@
|
||||
"[jsonc]": {
|
||||
"editor.minimap.markSectionHeaderRegex": "#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)$"
|
||||
},
|
||||
"[xml]": {
|
||||
"editor.minimap.markSectionHeaderRegex": "<!--\\s*#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)\\s*-->"
|
||||
},
|
||||
"todo-tree.tree.showCountsInTree": true,
|
||||
"todo-tree.tree.showBadges": true,
|
||||
"yaml.customTags": [
|
||||
|
||||
@@ -28,6 +28,8 @@ packages/django-channels-postgres @goauthentik/backend
|
||||
packages/django-postgres-cache @goauthentik/backend
|
||||
packages/django-dramatiq-postgres @goauthentik/backend
|
||||
# Web packages
|
||||
packages/package.json @goauthentik/backend @goauthentik/frontend
|
||||
packages/package-lock.json @goauthentik/backend @goauthentik/frontend
|
||||
packages/docusaurus-config @goauthentik/frontend
|
||||
packages/esbuild-plugin-live-reload @goauthentik/frontend
|
||||
packages/eslint-config @goauthentik/frontend
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.4-trixie@sha256:728cbef6ce5da50a5da2455cf8a13ddc4f71eb5a3245d9a5a3cce260f8ca9898 AS go-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:4f9d98ebaa759f776496d850e0439c48948d587b191fc3949b5f5e4667abef90 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -76,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.10@sha256:29bd45092ea8902c0bbb7f0a338f0494a382b1f4b18355df5be270ade679ff1d AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.15@sha256:4c1ad814fe658851f50ff95ecd6948673fffddb0d7994bdb019dcb58227abd52 AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
|
||||
|
||||
@@ -163,10 +163,11 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
||||
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
|
||||
mkdir -p /certs /media /blueprints && \
|
||||
mkdir -p /certs /data /media /blueprints && \
|
||||
ln -s /media /data/media && \
|
||||
mkdir -p /authentik/.ssh && \
|
||||
mkdir -p /ak-root && \
|
||||
chown authentik:authentik /certs /media /authentik/.ssh /ak-root
|
||||
chown authentik:authentik /certs /data /data/media /media /authentik/.ssh /ak-root
|
||||
|
||||
COPY ./authentik/ /authentik
|
||||
COPY ./pyproject.toml /
|
||||
|
||||
34
Makefile
34
Makefile
@@ -17,21 +17,20 @@ pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/
|
||||
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
|
||||
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
|
||||
|
||||
UNAME := $(shell uname)
|
||||
|
||||
# For macOS users, add the libxml2 installed from brew libxmlsec1 to the build path
|
||||
# to prevent SAML-related tests from failing and ensure correct pip dependency compilation
|
||||
ifeq ($(UNAME), Darwin)
|
||||
# Only add for brew users who installed libxmlsec1
|
||||
BREW_EXISTS := $(shell command -v brew 2> /dev/null)
|
||||
ifdef BREW_EXISTS
|
||||
LIBXML2_EXISTS := $(shell brew list libxml2 2> /dev/null)
|
||||
ifdef LIBXML2_EXISTS
|
||||
BREW_LDFLAGS := -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
|
||||
BREW_CPPFLAGS := -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
|
||||
BREW_PKG_CONFIG_PATH := $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
|
||||
endif
|
||||
endif
|
||||
# These functions are only evaluated when called in specific targets
|
||||
LIBXML2_EXISTS = $(shell brew list libxml2 2> /dev/null)
|
||||
KRB5_EXISTS = $(shell brew list krb5 2> /dev/null)
|
||||
|
||||
LIBXML2_LDFLAGS = -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
|
||||
LIBXML2_CPPFLAGS = -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
|
||||
LIBXML2_PKG_CONFIG = $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
|
||||
|
||||
KRB_PATH =
|
||||
|
||||
ifneq ($(KRB5_EXISTS),)
|
||||
KRB_PATH = PATH="$(shell brew --prefix krb5)/sbin:$(shell brew --prefix krb5)/bin:$$PATH"
|
||||
endif
|
||||
|
||||
all: lint-fix lint gen web test ## Lint, build, and test everything
|
||||
@@ -50,7 +49,7 @@ go-test:
|
||||
go test -timeout 0 -v -race -cover ./...
|
||||
|
||||
test: ## Run the server tests and produce a coverage report (locally)
|
||||
uv run coverage run manage.py test --keepdb authentik
|
||||
$(KRB_PATH) uv run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
|
||||
uv run coverage html
|
||||
uv run coverage report
|
||||
|
||||
@@ -66,11 +65,11 @@ lint: ## Lint the python and golang sources
|
||||
golangci-lint run -v
|
||||
|
||||
core-install:
|
||||
ifdef LIBXML2_EXISTS
|
||||
ifneq ($(LIBXML2_EXISTS),)
|
||||
# Clear cache to ensure fresh compilation
|
||||
uv cache clean
|
||||
# Force compilation from source for lxml and xmlsec with correct environment
|
||||
LDFLAGS="$(BREW_LDFLAGS)" CPPFLAGS="$(BREW_CPPFLAGS)" PKG_CONFIG_PATH="$(BREW_PKG_CONFIG_PATH)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
|
||||
LDFLAGS="$(LIBXML2_LDFLAGS)" CPPFLAGS="$(LIBXML2_CPPFLAGS)" PKG_CONFIG_PATH="$(LIBXML2_PKG_CONFIG)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
|
||||
else
|
||||
uv sync --frozen
|
||||
endif
|
||||
@@ -197,11 +196,12 @@ endif
|
||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
|
||||
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
|
||||
|
||||
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
||||
gen-client-go: ## Build and install the authentik API for Golang
|
||||
mkdir -p ${PWD}/${GEN_API_GO}
|
||||
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
|
||||
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
|
||||
else
|
||||
cd ${PWD}/${GEN_API_GO} && git reset --hard
|
||||
cd ${PWD}/${GEN_API_GO} && git pull
|
||||
endif
|
||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
|
||||
|
||||
258
authentik/admin/files/api.py
Normal file
258
authentik/admin/files/api.py
Normal file
@@ -0,0 +1,258 @@
|
||||
import mimetypes
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField, CharField, ChoiceField, FileField
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from authentik.admin.files.fields import FileField as AkFileField
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileApiUsage
|
||||
from authentik.admin.files.validation import validate_upload_file_name
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.used_by import DeleteAction, UsedBySerializer
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.reflection import get_apps
|
||||
from authentik.rbac.permissions import HasPermission
|
||||
|
||||
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25MB
|
||||
|
||||
|
||||
def get_mime_from_filename(filename: str) -> str:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
return mime_type or "application/octet-stream"
|
||||
|
||||
|
||||
class FileView(APIView):
|
||||
pagination_class = None
|
||||
parser_classes = [MultiPartParser]
|
||||
|
||||
def get_permissions(self):
|
||||
return [
|
||||
HasPermission(
|
||||
"authentik_rbac.view_media_files"
|
||||
if self.request.method in SAFE_METHODS
|
||||
else "authentik_rbac.manage_media_files"
|
||||
)()
|
||||
]
|
||||
|
||||
class FileListParameters(PassiveSerializer):
|
||||
usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
|
||||
search = CharField(required=False)
|
||||
manageable_only = BooleanField(required=False, default=False)
|
||||
|
||||
class FileListSerializer(PassiveSerializer):
|
||||
name = CharField()
|
||||
mime_type = CharField()
|
||||
url = CharField()
|
||||
|
||||
@extend_schema(
|
||||
parameters=[FileListParameters],
|
||||
responses={200: FileListSerializer(many=True)},
|
||||
)
|
||||
@validate(FileListParameters, location="query")
|
||||
def get(self, request: Request, query: FileListParameters) -> Response:
|
||||
"""List files from storage backend."""
|
||||
params = query.validated_data
|
||||
|
||||
try:
|
||||
usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
f"Invalid usage parameter provided: {params.get('usage')}"
|
||||
) from exc
|
||||
|
||||
# Backend is source of truth - list all files from storage
|
||||
manager = get_file_manager(usage)
|
||||
files = manager.list_files(manageable_only=params.get("manageable_only", False))
|
||||
search_query = params.get("search", "")
|
||||
if search_query:
|
||||
files = filter(lambda file: search_query in file.lower(), files)
|
||||
files = [
|
||||
FileView.FileListSerializer(
|
||||
data={
|
||||
"name": file,
|
||||
"url": manager.file_url(file),
|
||||
"mime_type": get_mime_from_filename(file),
|
||||
}
|
||||
)
|
||||
for file in files
|
||||
]
|
||||
for file in files:
|
||||
file.is_valid(raise_exception=True)
|
||||
|
||||
return Response([file.data for file in files])
|
||||
|
||||
class FileUploadSerializer(PassiveSerializer):
|
||||
file = FileField(required=True)
|
||||
name = CharField(required=False, allow_blank=True)
|
||||
usage = CharField(required=False, default=FileApiUsage.MEDIA.value)
|
||||
|
||||
@extend_schema(
|
||||
request=FileUploadSerializer,
|
||||
responses={200: None},
|
||||
)
|
||||
@validate(FileUploadSerializer)
|
||||
def post(self, request: Request, body: FileUploadSerializer) -> Response:
|
||||
"""Upload file to storage backend."""
|
||||
file = body.validated_data["file"]
|
||||
name = body.validated_data.get("name", "").strip()
|
||||
usage_value = body.validated_data.get("usage", FileApiUsage.MEDIA.value)
|
||||
|
||||
# Validate file size and type
|
||||
if file.size > MAX_FILE_SIZE_BYTES:
|
||||
raise ValidationError(
|
||||
{
|
||||
"file": [
|
||||
_(
|
||||
f"File size ({file.size}B) exceeds maximum allowed "
|
||||
f"size ({MAX_FILE_SIZE_BYTES}B)."
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
usage = FileApiUsage(usage_value)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"Invalid usage parameter provided: {usage_value}") from exc
|
||||
|
||||
# Use original filename
|
||||
if not name:
|
||||
name = file.name
|
||||
|
||||
# Sanitize path to prevent directory traversal
|
||||
validate_upload_file_name(name, ValidationError)
|
||||
|
||||
manager = get_file_manager(usage)
|
||||
|
||||
# Check if file already exists
|
||||
if manager.file_exists(name):
|
||||
raise ValidationError({"name": ["A file with this name already exists."]})
|
||||
|
||||
# Save to backend
|
||||
with manager.save_file_stream(name) as f:
|
||||
f.write(file.read())
|
||||
|
||||
Event.new(
|
||||
EventAction.MODEL_CREATED,
|
||||
model={
|
||||
"app": "authentik_admin_files",
|
||||
"model_name": "File",
|
||||
"pk": name,
|
||||
"name": name,
|
||||
"usage": usage.value,
|
||||
"mime_type": get_mime_from_filename(name),
|
||||
},
|
||||
).from_http(request)
|
||||
|
||||
return Response()
|
||||
|
||||
class FileDeleteParameters(PassiveSerializer):
|
||||
name = CharField()
|
||||
usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[FileDeleteParameters],
|
||||
responses={200: None},
|
||||
)
|
||||
@validate(FileDeleteParameters, location="query")
|
||||
def delete(self, request: Request, query: FileDeleteParameters) -> Response:
|
||||
"""Delete file from storage backend."""
|
||||
params = query.validated_data
|
||||
|
||||
validate_upload_file_name(params.get("name", ""), ValidationError)
|
||||
|
||||
try:
|
||||
usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
f"Invalid usage parameter provided: {params.get('usage')}"
|
||||
) from exc
|
||||
|
||||
manager = get_file_manager(usage)
|
||||
|
||||
# Delete from backend
|
||||
manager.delete_file(params.get("name"))
|
||||
|
||||
# Audit log for file deletion
|
||||
Event.new(
|
||||
EventAction.MODEL_DELETED,
|
||||
model={
|
||||
"app": "authentik_admin_files",
|
||||
"model_name": "File",
|
||||
"pk": params.get("name"),
|
||||
"name": params.get("name"),
|
||||
"usage": usage.value,
|
||||
},
|
||||
).from_http(request)
|
||||
|
||||
return Response()
|
||||
|
||||
|
||||
class FileUsedByView(APIView):
|
||||
pagination_class = None
|
||||
|
||||
def get_permissions(self):
|
||||
return [
|
||||
HasPermission(
|
||||
"authentik_rbac.view_media_files"
|
||||
if self.request.method in SAFE_METHODS
|
||||
else "authentik_rbac.manage_media_files"
|
||||
)()
|
||||
]
|
||||
|
||||
class FileUsedByParameters(PassiveSerializer):
|
||||
name = CharField()
|
||||
|
||||
@extend_schema(
|
||||
parameters=[FileUsedByParameters],
|
||||
responses={200: UsedBySerializer(many=True)},
|
||||
)
|
||||
@validate(FileUsedByParameters, location="query")
|
||||
def get(self, request: Request, query: FileUsedByParameters) -> Response:
|
||||
params = query.validated_data
|
||||
|
||||
models_and_fields = {}
|
||||
for app in get_apps():
|
||||
for model in app.get_models():
|
||||
if model._meta.abstract:
|
||||
continue
|
||||
for field in model._meta.get_fields():
|
||||
if isinstance(field, AkFileField):
|
||||
models_and_fields.setdefault(model, []).append(field.name)
|
||||
|
||||
used_by = []
|
||||
|
||||
for model, fields in models_and_fields.items():
|
||||
app = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{field: params.get("name")})
|
||||
|
||||
objs = get_objects_for_user(request.user, f"{app}.view_{model_name}", model)
|
||||
objs = objs.filter(q)
|
||||
for obj in objs:
|
||||
serializer = UsedBySerializer(
|
||||
data={
|
||||
"app": model._meta.app_label,
|
||||
"model_name": model._meta.model_name,
|
||||
"pk": str(obj.pk),
|
||||
"name": str(obj),
|
||||
"action": DeleteAction.LEFT_DANGLING,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
used_by.append(serializer.data)
|
||||
|
||||
return Response(used_by)
|
||||
8
authentik/admin/files/apps.py
Normal file
8
authentik/admin/files/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
class AuthentikFilesConfig(ManagedAppConfig):
|
||||
name = "authentik.admin.files"
|
||||
label = "authentik_admin_files"
|
||||
verbose_name = "authentik Files"
|
||||
default = True
|
||||
0
authentik/admin/files/backends/__init__.py
Normal file
0
authentik/admin/files/backends/__init__.py
Normal file
134
authentik/admin/files/backends/base.py
Normal file
134
authentik/admin/files/backends/base.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class Backend:
|
||||
"""
|
||||
Base class for file storage backends.
|
||||
|
||||
Class attributes:
|
||||
allowed_usages: List of usages that can be used with this backend
|
||||
"""
|
||||
|
||||
allowed_usages: list[FileUsage]
|
||||
|
||||
def __init__(self, usage: FileUsage):
|
||||
"""
|
||||
Initialize backend for the given usage type.
|
||||
|
||||
Args:
|
||||
usage: FileUsage type enum value
|
||||
"""
|
||||
self.usage = usage
|
||||
LOGGER.debug(
|
||||
"Initializing storage backend",
|
||||
backend=self.__class__.__name__,
|
||||
usage=usage.value,
|
||||
)
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""
|
||||
Check if this backend can handle the given file path.
|
||||
|
||||
Args:
|
||||
name: File path to check
|
||||
|
||||
Returns:
|
||||
True if this backend supports this file path
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""
|
||||
List all files stored in this backend.
|
||||
|
||||
Yields:
|
||||
Relative file paths
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""
|
||||
Get URL for accessing the file.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
request: Optional Django HttpRequest for fully qualifed URL building
|
||||
|
||||
Returns:
|
||||
URL to access the file (may be relative or absolute depending on backend)
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ManageableBackend(Backend):
|
||||
"""
|
||||
Base class for manageable file storage backends.
|
||||
|
||||
Class attributes:
|
||||
name: Canonical name of the storage backend, for use in configuration.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
"""
|
||||
Whether this backend can actually be used for management.
|
||||
|
||||
Used only for management check, not for created the backend
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""
|
||||
Save file content to storage.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
content: File content as bytes
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def save_file_stream(self, name: str) -> Iterator:
|
||||
"""
|
||||
Context manager for streaming file writes.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
|
||||
Returns:
|
||||
Context manager that yields a writable file-like object
|
||||
|
||||
FileUsage:
|
||||
with backend.save_file_stream("output.csv") as f:
|
||||
f.write(b"data...")
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def delete_file(self, name: str) -> None:
|
||||
"""
|
||||
Delete file from storage.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def file_exists(self, name: str) -> bool:
|
||||
"""
|
||||
Check if a file exists.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
|
||||
Returns:
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
raise NotImplementedError
|
||||
114
authentik/admin/files/backends/file.py
Normal file
114
authentik/admin/files/backends/file.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
from collections.abc import Generator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
|
||||
from authentik.admin.files.backends.base import ManageableBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
|
||||
|
||||
class FileBackend(ManageableBackend):
|
||||
"""Local filesystem backend for file storage.
|
||||
|
||||
Stores files in a local directory structure:
|
||||
- Path: {base_dir}/{usage}/{schema}/{filename}
|
||||
- Supports full file management (upload, delete, list)
|
||||
- Used when storage.backend=file (default)
|
||||
"""
|
||||
|
||||
name = "file"
|
||||
allowed_usages = list(FileUsage) # All usages
|
||||
|
||||
@property
|
||||
def _base_dir(self) -> Path:
|
||||
return Path(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.path",
|
||||
CONFIG.get(f"storage.{self.name}.path", "./data"),
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def base_path(self) -> Path:
|
||||
"""Path structure: {base_dir}/{usage}/{schema}"""
|
||||
return self._base_dir / self.usage.value / connection.schema_name
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
return (
|
||||
self.base_path.exists()
|
||||
and (self._base_dir.is_mount() or (self._base_dir / self.usage.value).is_mount())
|
||||
or (settings.DEBUG or settings.TEST)
|
||||
)
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""We support all files"""
|
||||
return True
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""List all files returning relative paths from base_path."""
|
||||
for root, _, files in os.walk(self.base_path):
|
||||
for file in files:
|
||||
full_path = Path(root) / file
|
||||
rel_path = full_path.relative_to(self.base_path)
|
||||
yield str(rel_path)
|
||||
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Get URL for accessing the file."""
|
||||
expires_in = timedelta_from_string(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.url_expiry",
|
||||
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
|
||||
)
|
||||
)
|
||||
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
path = f"{self.usage.value}/{connection.schema_name}/{name}"
|
||||
token = jwt.encode(
|
||||
payload={
|
||||
"path": path,
|
||||
"exp": now() + expires_in,
|
||||
"nbf": now() - timedelta(seconds=15),
|
||||
},
|
||||
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
url = f"{prefix}/files/{path}?token={token}"
|
||||
if request is None:
|
||||
return url
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""Save file to local filesystem."""
|
||||
path = self.base_path / Path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w+b") as f:
|
||||
f.write(content)
|
||||
|
||||
@contextmanager
|
||||
def save_file_stream(self, name: str) -> Iterator:
|
||||
"""Context manager for streaming file writes to local filesystem."""
|
||||
path = self.base_path / Path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
yield f
|
||||
|
||||
def delete_file(self, name: str) -> None:
|
||||
"""Delete file from local filesystem."""
|
||||
path = self.base_path / Path(name)
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def file_exists(self, name: str) -> bool:
|
||||
"""Check if a file exists."""
|
||||
path = self.base_path / Path(name)
|
||||
return path.exists()
|
||||
43
authentik/admin/files/backends/passthrough.py
Normal file
43
authentik/admin/files/backends/passthrough.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
from authentik.admin.files.backends.base import Backend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
EXTERNAL_URL_SCHEMES = ["http:", "https://"]
|
||||
FONT_AWESOME_SCHEME = "fa://"
|
||||
|
||||
|
||||
class PassthroughBackend(Backend):
|
||||
"""Passthrough backend for external URLs and special schemes.
|
||||
|
||||
Handles external resources that aren't stored in authentik:
|
||||
- Font Awesome icons (fa://...)
|
||||
- HTTP/HTTPS URLs (http://..., https://...)
|
||||
|
||||
Files that are "managed" by this backend are just passed through as-is.
|
||||
No upload, delete, or listing operations are supported.
|
||||
Only accessible through resolve_file_url when an external URL is detected.
|
||||
"""
|
||||
|
||||
allowed_usages = [FileUsage.MEDIA]
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""Check if file path is an external URL or Font Awesome icon."""
|
||||
if name.startswith(FONT_AWESOME_SCHEME):
|
||||
return True
|
||||
|
||||
for scheme in EXTERNAL_URL_SCHEMES:
|
||||
if name.startswith(scheme):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""External files cannot be listed."""
|
||||
yield from []
|
||||
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Return the URL as-is for passthrough files."""
|
||||
return name
|
||||
213
authentik/admin/files/backends/s3.py
Normal file
213
authentik/admin/files/backends/s3.py
Normal file
@@ -0,0 +1,213 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from tempfile import SpooledTemporaryFile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
from django.db import connection
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
from authentik.admin.files.backends.base import ManageableBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
|
||||
|
||||
class S3Backend(ManageableBackend):
|
||||
"""S3-compatible object storage backend.
|
||||
|
||||
Stores files in s3-compatible storage:
|
||||
- Key prefix: {usage}/{schema}/{filename}
|
||||
- Supports full file management (upload, delete, list)
|
||||
- Generates presigned URLs for file access
|
||||
- Used when storage.backend=s3
|
||||
"""
|
||||
|
||||
allowed_usages = list(FileUsage) # All usages
|
||||
name = "s3"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._config = {}
|
||||
self._session = None
|
||||
|
||||
def _get_config(self, key: str, default: str | None) -> tuple[str | None, bool]:
|
||||
unset = object()
|
||||
current = self._config.get(key, unset)
|
||||
refreshed = CONFIG.refresh(
|
||||
f"storage.{self.usage.value}.{self.name}.{key}",
|
||||
CONFIG.refresh(f"storage.{self.name}.{key}", default),
|
||||
)
|
||||
if current is unset:
|
||||
current = refreshed
|
||||
self._config[key] = refreshed
|
||||
return (refreshed, current != refreshed)
|
||||
|
||||
@property
|
||||
def base_path(self) -> str:
|
||||
"""S3 key prefix: {usage}/{schema}/"""
|
||||
return f"{self.usage.value}/{connection.schema_name}"
|
||||
|
||||
@property
|
||||
def bucket_name(self) -> str:
|
||||
return CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.bucket_name",
|
||||
CONFIG.get(f"storage.{self.name}.bucket_name"),
|
||||
)
|
||||
|
||||
@property
|
||||
def session(self) -> boto3.Session:
|
||||
"""Create boto3 session with configured credentials."""
|
||||
session_profile, session_profile_r = self._get_config("session_profile", None)
|
||||
if session_profile is not None:
|
||||
if session_profile_r or self._session is None:
|
||||
self._session = boto3.Session(profile_name=session_profile)
|
||||
return self._session
|
||||
else:
|
||||
return self._session
|
||||
else:
|
||||
access_key, access_key_r = self._get_config("access_key", None)
|
||||
secret_key, secret_key_r = self._get_config("secret_key", None)
|
||||
session_token, session_token_r = self._get_config("session_token", None)
|
||||
if access_key_r or secret_key_r or session_token_r or self._session is None:
|
||||
self._session = boto3.Session(
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
aws_session_token=session_token,
|
||||
)
|
||||
return self._session
|
||||
else:
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Create S3 client with configured endpoint and region."""
|
||||
endpoint_url = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.endpoint",
|
||||
CONFIG.get(f"storage.{self.name}.endpoint", None),
|
||||
)
|
||||
use_ssl = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.use_ssl",
|
||||
CONFIG.get(f"storage.{self.name}.use_ssl", True),
|
||||
)
|
||||
region_name = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.region",
|
||||
CONFIG.get(f"storage.{self.name}.region", None),
|
||||
)
|
||||
addressing_style = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.addressing_style",
|
||||
CONFIG.get(f"storage.{self.name}.addressing_style", "auto"),
|
||||
)
|
||||
|
||||
return self.session.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint_url,
|
||||
use_ssl=use_ssl,
|
||||
region_name=region_name,
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": addressing_style}),
|
||||
)
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""We support all files"""
|
||||
return True
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""List all files returning relative paths from base_path."""
|
||||
paginator = self.client.get_paginator("list_objects_v2")
|
||||
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=f"{self.base_path}/")
|
||||
|
||||
for page in pages:
|
||||
for obj in page.get("Contents", []):
|
||||
key = obj["Key"]
|
||||
# Remove base path prefix to get relative path
|
||||
rel_path = key.removeprefix(f"{self.base_path}/")
|
||||
if rel_path: # Skip if it's just the directory itself
|
||||
yield rel_path
|
||||
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Generate presigned URL for file access."""
|
||||
use_https = CONFIG.get_bool(
|
||||
f"storage.{self.usage.value}.{self.name}.secure_urls",
|
||||
CONFIG.get_bool(f"storage.{self.name}.secure_urls", True),
|
||||
)
|
||||
|
||||
params = {
|
||||
"Bucket": self.bucket_name,
|
||||
"Key": f"{self.base_path}/{name}",
|
||||
}
|
||||
|
||||
expires_in = timedelta_from_string(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.url_expiry",
|
||||
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
|
||||
)
|
||||
)
|
||||
|
||||
url = self.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=expires_in.total_seconds(),
|
||||
HttpMethod="GET",
|
||||
)
|
||||
|
||||
# Support custom domain for S3-compatible storage (so not AWS)
|
||||
# Well, can't you do custom domains on AWS as well?
|
||||
custom_domain = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.custom_domain",
|
||||
CONFIG.get(f"storage.{self.name}.custom_domain", None),
|
||||
)
|
||||
if custom_domain:
|
||||
parsed = urlsplit(url)
|
||||
scheme = "https" if use_https else "http"
|
||||
url = f"{scheme}://{custom_domain}{parsed.path}?{parsed.query}"
|
||||
|
||||
return url
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""Save file to S3."""
|
||||
self.client.put_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=f"{self.base_path}/{name}",
|
||||
Body=content,
|
||||
ACL="private",
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def save_file_stream(self, name: str) -> Iterator:
|
||||
"""Context manager for streaming file writes to S3."""
|
||||
# Keep files in memory up to 5 MB
|
||||
with SpooledTemporaryFile(max_size=5 * 1024 * 1024, suffix=".S3File") as file:
|
||||
yield file
|
||||
file.seek(0)
|
||||
self.client.upload_fileobj(
|
||||
Fileobj=file,
|
||||
Bucket=self.bucket_name,
|
||||
Key=f"{self.base_path}/{name}",
|
||||
ExtraArgs={
|
||||
"ACL": "private",
|
||||
},
|
||||
)
|
||||
|
||||
def delete_file(self, name: str) -> None:
|
||||
"""Delete file from S3."""
|
||||
self.client.delete_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=f"{self.base_path}/{name}",
|
||||
)
|
||||
|
||||
def file_exists(self, name: str) -> bool:
|
||||
"""Check if a file exists in S3."""
|
||||
try:
|
||||
self.client.head_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=f"{self.base_path}/{name}",
|
||||
)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
53
authentik/admin/files/backends/static.py
Normal file
53
authentik/admin/files/backends/static.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
from authentik.admin.files.backends.base import Backend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
STATIC_ASSETS_BASE_DIR = Path("web/dist")
|
||||
STATIC_ASSETS_DIRS = [Path(p) for p in ("assets/icons", "assets/images")]
|
||||
STATIC_ASSETS_SOURCES_DIR = Path("web/authentik/sources")
|
||||
STATIC_FILE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg"]
|
||||
STATIC_PATH_PREFIX = "/static"
|
||||
|
||||
|
||||
class StaticBackend(Backend):
|
||||
"""Read-only backend for static files from web/dist/assets.
|
||||
|
||||
- Used for serving built-in static assets like icons and images.
|
||||
- Files cannot be uploaded or deleted through this backend.
|
||||
- Only accessible through resolve_file_url when a static path is detected.
|
||||
"""
|
||||
|
||||
allowed_usages = [FileUsage.MEDIA]
|
||||
|
||||
def supports_file(self, name: str) -> bool:
|
||||
"""Check if file path is a static path."""
|
||||
return name.startswith(STATIC_PATH_PREFIX)
|
||||
|
||||
def list_files(self) -> Generator[str]:
|
||||
"""List all static files."""
|
||||
# List built-in source icons
|
||||
if STATIC_ASSETS_SOURCES_DIR.exists():
|
||||
for file_path in STATIC_ASSETS_SOURCES_DIR.iterdir():
|
||||
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
|
||||
yield f"{STATIC_PATH_PREFIX}/authentik/sources/{file_path.name}"
|
||||
|
||||
# List other static assets
|
||||
for dir in STATIC_ASSETS_DIRS:
|
||||
dist_dir = STATIC_ASSETS_BASE_DIR / dir
|
||||
if dist_dir.exists():
|
||||
for file_path in dist_dir.rglob("*"):
|
||||
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
|
||||
yield f"{STATIC_PATH_PREFIX}/dist/{dir}/{file_path.name}"
|
||||
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Get URL for static file."""
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
url = f"{prefix}{name}"
|
||||
if request is None:
|
||||
return url
|
||||
return request.build_absolute_uri(url)
|
||||
0
authentik/admin/files/backends/tests/__init__.py
Normal file
0
authentik/admin/files/backends/tests/__init__.py
Normal file
167
authentik/admin/files/backends/tests/test_file_backend.py
Normal file
167
authentik/admin/files/backends/tests/test_file_backend.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.backends.file import FileBackend
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class TestFileBackend(FileTestFileBackendMixin, TestCase):
|
||||
"""Test FileBackend class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
super().setUp()
|
||||
self.backend = FileBackend(FileUsage.MEDIA)
|
||||
|
||||
def test_allowed_usages(self):
|
||||
"""Test that FileBackend supports all usage types"""
|
||||
self.assertEqual(self.backend.allowed_usages, list(FileUsage))
|
||||
|
||||
def test_base_path(self):
|
||||
"""Test base_path property constructs correct path"""
|
||||
base_path = self.backend.base_path
|
||||
|
||||
expected = Path(self.media_backend_path) / "media" / "public"
|
||||
self.assertEqual(base_path, expected)
|
||||
|
||||
def test_base_path_reports_usage(self):
|
||||
"""Test base_path with reports usage"""
|
||||
backend = FileBackend(FileUsage.REPORTS)
|
||||
base_path = backend.base_path
|
||||
|
||||
expected = Path(self.reports_backend_path) / "reports" / "public"
|
||||
self.assertEqual(base_path, expected)
|
||||
|
||||
def test_list_files_empty_directory(self):
|
||||
"""Test list_files returns empty when directory is empty"""
|
||||
# Create the directory but keep it empty
|
||||
self.backend.base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files = list(self.backend.list_files())
|
||||
self.assertEqual(files, [])
|
||||
|
||||
def test_list_files_with_files(self):
|
||||
"""Test list_files returns all files in directory"""
|
||||
base_path = self.backend.base_path
|
||||
base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create some test files
|
||||
(base_path / "file1.txt").write_text("content1")
|
||||
(base_path / "file2.png").write_text("content2")
|
||||
(base_path / "subdir").mkdir()
|
||||
(base_path / "subdir" / "file3.csv").write_text("content3")
|
||||
|
||||
files = sorted(list(self.backend.list_files()))
|
||||
expected = sorted(["file1.txt", "file2.png", "subdir/file3.csv"])
|
||||
self.assertEqual(files, expected)
|
||||
|
||||
def test_list_files_nonexistent_directory(self):
|
||||
"""Test list_files returns empty when directory doesn't exist"""
|
||||
files = list(self.backend.list_files())
|
||||
self.assertEqual(files, [])
|
||||
|
||||
def test_save_file(self):
|
||||
content = b"test file content"
|
||||
file_name = "test.txt"
|
||||
|
||||
self.backend.save_file(file_name, content)
|
||||
|
||||
# Verify file was created
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
self.assertEqual(file_path.read_bytes(), content)
|
||||
|
||||
def test_save_file_creates_subdirectories(self):
|
||||
"""Test save_file creates parent directories as needed"""
|
||||
content = b"nested file content"
|
||||
file_name = "subdir1/subdir2/nested.txt"
|
||||
|
||||
self.backend.save_file(file_name, content)
|
||||
|
||||
# Verify file and directories were created
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
self.assertEqual(file_path.read_bytes(), content)
|
||||
|
||||
def test_save_file_stream(self):
|
||||
"""Test save_file_stream context manager writes file correctly"""
|
||||
content = b"streamed content"
|
||||
file_name = "stream_test.txt"
|
||||
|
||||
with self.backend.save_file_stream(file_name) as f:
|
||||
f.write(content)
|
||||
|
||||
# Verify file was created
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
self.assertEqual(file_path.read_bytes(), content)
|
||||
|
||||
def test_save_file_stream_creates_subdirectories(self):
|
||||
"""Test save_file_stream creates parent directories as needed"""
|
||||
content = b"nested stream content"
|
||||
file_name = "dir1/dir2/stream.bin"
|
||||
|
||||
with self.backend.save_file_stream(file_name) as f:
|
||||
f.write(content)
|
||||
|
||||
# Verify file and directories were created
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
self.assertEqual(file_path.read_bytes(), content)
|
||||
|
||||
def test_delete_file(self):
|
||||
"""Test delete_file removes existing file"""
|
||||
file_name = "to_delete.txt"
|
||||
|
||||
# Create file first
|
||||
self.backend.save_file(file_name, b"content")
|
||||
file_path = self.backend.base_path / file_name
|
||||
self.assertTrue(file_path.exists())
|
||||
|
||||
# Delete it
|
||||
self.backend.delete_file(file_name)
|
||||
self.assertFalse(file_path.exists())
|
||||
|
||||
def test_delete_file_nonexistent(self):
|
||||
"""Test delete_file handles nonexistent file gracefully"""
|
||||
file_name = "does_not_exist.txt"
|
||||
self.backend.delete_file(file_name)
|
||||
|
||||
def test_file_url(self):
|
||||
"""Test file_url generates correct URL"""
|
||||
file_name = "icon.png"
|
||||
|
||||
url = self.backend.file_url(file_name).split("?")[0]
|
||||
expected = "/files/media/public/icon.png"
|
||||
self.assertEqual(url, expected)
|
||||
|
||||
@CONFIG.patch("web.path", "/authentik/")
|
||||
def test_file_url_with_prefix(self):
|
||||
"""Test file_url with web path prefix"""
|
||||
file_name = "logo.svg"
|
||||
|
||||
url = self.backend.file_url(file_name).split("?")[0]
|
||||
expected = "/authentik/files/media/public/logo.svg"
|
||||
self.assertEqual(url, expected)
|
||||
|
||||
def test_file_url_nested_path(self):
|
||||
"""Test file_url with nested file path"""
|
||||
file_name = "path/to/file.png"
|
||||
|
||||
url = self.backend.file_url(file_name).split("?")[0]
|
||||
expected = "/files/media/public/path/to/file.png"
|
||||
self.assertEqual(url, expected)
|
||||
|
||||
def test_file_exists_true(self):
|
||||
"""Test file_exists returns True for existing file"""
|
||||
file_name = "exists.txt"
|
||||
self.backend.base_path.mkdir(parents=True, exist_ok=True)
|
||||
(self.backend.base_path / file_name).touch()
|
||||
self.assertTrue(self.backend.file_exists(file_name))
|
||||
|
||||
def test_file_exists_false(self):
|
||||
"""Test file_exists returns False for nonexistent file"""
|
||||
self.assertFalse(self.backend.file_exists("does_not_exist.txt"))
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Test passthrough backend"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.backends.passthrough import PassthroughBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
|
||||
class TestPassthroughBackend(TestCase):
|
||||
"""Test PassthroughBackend class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.backend = PassthroughBackend(FileUsage.MEDIA)
|
||||
|
||||
def test_allowed_usages(self):
|
||||
"""Test that PassthroughBackend only supports MEDIA usage"""
|
||||
self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA])
|
||||
|
||||
def test_supports_file_path_font_awesome(self):
|
||||
"""Test supports_file_path returns True for Font Awesome icons"""
|
||||
self.assertTrue(self.backend.supports_file("fa://user"))
|
||||
self.assertTrue(self.backend.supports_file("fa://home"))
|
||||
self.assertTrue(self.backend.supports_file("fa://shield"))
|
||||
|
||||
def test_supports_file_path_http(self):
|
||||
"""Test supports_file_path returns True for HTTP URLs"""
|
||||
self.assertTrue(self.backend.supports_file("http://example.com/icon.png"))
|
||||
self.assertTrue(self.backend.supports_file("http://cdn.example.com/logo.svg"))
|
||||
|
||||
def test_supports_file_path_https(self):
|
||||
"""Test supports_file_path returns True for HTTPS URLs"""
|
||||
self.assertTrue(self.backend.supports_file("https://example.com/icon.png"))
|
||||
self.assertTrue(self.backend.supports_file("https://cdn.example.com/logo.svg"))
|
||||
|
||||
def test_supports_file_path_false(self):
|
||||
"""Test supports_file_path returns False for regular paths"""
|
||||
self.assertFalse(self.backend.supports_file("icon.png"))
|
||||
self.assertFalse(self.backend.supports_file("/static/icon.png"))
|
||||
self.assertFalse(self.backend.supports_file("media/logo.svg"))
|
||||
self.assertFalse(self.backend.supports_file(""))
|
||||
|
||||
def test_supports_file_path_invalid_scheme(self):
|
||||
"""Test supports_file_path returns False for invalid schemes"""
|
||||
self.assertFalse(self.backend.supports_file("ftp://example.com/file.png"))
|
||||
self.assertFalse(self.backend.supports_file("file:///path/to/file.png"))
|
||||
self.assertFalse(self.backend.supports_file("data:image/png;base64,abc123"))
|
||||
|
||||
def test_list_files(self):
|
||||
"""Test list_files returns empty generator"""
|
||||
files = list(self.backend.list_files())
|
||||
self.assertEqual(files, [])
|
||||
|
||||
def test_file_url(self):
|
||||
"""Test file_url returns the URL as-is"""
|
||||
url = "https://example.com/icon.png"
|
||||
self.assertEqual(self.backend.file_url(url), url)
|
||||
|
||||
def test_file_url_font_awesome(self):
|
||||
"""Test file_url returns Font Awesome URL as-is"""
|
||||
url = "fa://user"
|
||||
self.assertEqual(self.backend.file_url(url), url)
|
||||
|
||||
def test_file_url_http(self):
|
||||
"""Test file_url returns HTTP URL as-is"""
|
||||
url = "http://cdn.example.com/logo.svg"
|
||||
self.assertEqual(self.backend.file_url(url), url)
|
||||
109
authentik/admin/files/backends/tests/test_s3_backend.py
Normal file
109
authentik/admin/files/backends/tests/test_s3_backend.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class TestS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
"""Test S3 backend functionality"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_base_path(self):
|
||||
"""Test base_path property generates correct S3 key prefix"""
|
||||
expected = "media/public"
|
||||
self.assertEqual(self.media_s3_backend.base_path, expected)
|
||||
|
||||
def test_supports_file_path_s3(self):
|
||||
"""Test supports_file_path returns True for s3 backend"""
|
||||
self.assertTrue(self.media_s3_backend.supports_file("path/to/any-file.png"))
|
||||
self.assertTrue(self.media_s3_backend.supports_file("any-file.png"))
|
||||
|
||||
def test_list_files(self):
|
||||
"""Test list_files returns relative paths"""
|
||||
self.media_s3_backend.client.put_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/file1.png",
|
||||
Body=b"test content",
|
||||
ACL="private",
|
||||
)
|
||||
self.media_s3_backend.client.put_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/other/file1.png",
|
||||
Body=b"test content",
|
||||
ACL="private",
|
||||
)
|
||||
|
||||
files = list(self.media_s3_backend.list_files())
|
||||
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertIn("file1.png", files)
|
||||
|
||||
def test_list_files_empty(self):
|
||||
"""Test list_files with no files"""
|
||||
files = list(self.media_s3_backend.list_files())
|
||||
|
||||
self.assertEqual(len(files), 0)
|
||||
|
||||
def test_save_file(self):
|
||||
"""Test save_file uploads to S3"""
|
||||
content = b"test file content"
|
||||
self.media_s3_backend.save_file("test.png", content)
|
||||
|
||||
def test_save_file_stream(self):
|
||||
"""Test save_file_stream uploads to S3 using context manager"""
|
||||
with self.media_s3_backend.save_file_stream("test.csv") as f:
|
||||
f.write(b"header1,header2\n")
|
||||
f.write(b"value1,value2\n")
|
||||
|
||||
def test_delete_file(self):
|
||||
"""Test delete_file removes from S3"""
|
||||
self.media_s3_backend.client.put_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/test.png",
|
||||
Body=b"test content",
|
||||
ACL="private",
|
||||
)
|
||||
self.media_s3_backend.delete_file("test.png")
|
||||
|
||||
@CONFIG.patch("storage.s3.secure_urls", True)
|
||||
@CONFIG.patch("storage.s3.custom_domain", None)
|
||||
def test_file_url_basic(self):
|
||||
"""Test file_url generates presigned URL with AWS signature format"""
|
||||
url = self.media_s3_backend.file_url("test.png")
|
||||
|
||||
self.assertIn("X-Amz-Algorithm=AWS4-HMAC-SHA256", url)
|
||||
self.assertIn("X-Amz-Signature=", url)
|
||||
self.assertIn("test.png", url)
|
||||
|
||||
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
|
||||
def test_file_exists_true(self):
|
||||
"""Test file_exists returns True for existing file"""
|
||||
self.media_s3_backend.client.put_object(
|
||||
Bucket=self.media_s3_bucket_name,
|
||||
Key="media/public/test.png",
|
||||
Body=b"test content",
|
||||
ACL="private",
|
||||
)
|
||||
|
||||
exists = self.media_s3_backend.file_exists("test.png")
|
||||
|
||||
self.assertTrue(exists)
|
||||
|
||||
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
|
||||
def test_file_exists_false(self):
|
||||
"""Test file_exists returns False for non-existent file"""
|
||||
exists = self.media_s3_backend.file_exists("nonexistent.png")
|
||||
|
||||
self.assertFalse(exists)
|
||||
|
||||
def test_allowed_usages(self):
|
||||
"""Test that S3Backend supports all usage types"""
|
||||
self.assertEqual(self.media_s3_backend.allowed_usages, list(FileUsage))
|
||||
|
||||
def test_reports_usage(self):
|
||||
"""Test S3Backend with REPORTS usage"""
|
||||
self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
|
||||
self.assertEqual(self.reports_s3_backend.base_path, "reports/public")
|
||||
42
authentik/admin/files/backends/tests/test_static_backend.py
Normal file
42
authentik/admin/files/backends/tests/test_static_backend.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.backends.static import StaticBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
|
||||
class TestStaticBackend(TestCase):
|
||||
"""Test Static backend functionality"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.usage = FileUsage.MEDIA
|
||||
self.backend = StaticBackend(self.usage)
|
||||
|
||||
def test_init(self):
|
||||
"""Test StaticBackend initialization"""
|
||||
self.assertEqual(self.backend.usage, self.usage)
|
||||
|
||||
def test_allowed_usages(self):
|
||||
"""Test that StaticBackend only supports MEDIA usage"""
|
||||
self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA])
|
||||
|
||||
def test_supports_file_path_static_prefix(self):
|
||||
"""Test supports_file_path returns True for /static prefix"""
|
||||
self.assertTrue(self.backend.supports_file("/static/assets/icons/test.svg"))
|
||||
self.assertTrue(self.backend.supports_file("/static/authentik/sources/icon.png"))
|
||||
|
||||
def test_supports_file_path_not_static(self):
|
||||
"""Test supports_file_path returns False for non-static paths"""
|
||||
self.assertFalse(self.backend.supports_file("web/dist/assets/icons/test.svg"))
|
||||
self.assertFalse(self.backend.supports_file("web/dist/assets/images/logo.png"))
|
||||
self.assertFalse(self.backend.supports_file("media/public/test.png"))
|
||||
self.assertFalse(self.backend.supports_file("/media/test.svg"))
|
||||
self.assertFalse(self.backend.supports_file("test.jpg"))
|
||||
|
||||
def test_list_files(self):
|
||||
"""Test list_files includes expected files"""
|
||||
files = list(self.backend.list_files())
|
||||
|
||||
self.assertIn("/static/authentik/sources/ldap.png", files)
|
||||
self.assertIn("/static/authentik/sources/openidconnect.svg", files)
|
||||
self.assertIn("/static/authentik/sources/saml.png", files)
|
||||
7
authentik/admin/files/fields.py
Normal file
7
authentik/admin/files/fields.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.db import models
|
||||
|
||||
from authentik.admin.files.validation import validate_file_name
|
||||
|
||||
|
||||
class FileField(models.TextField):
|
||||
default_validators = [validate_file_name]
|
||||
141
authentik/admin/files/manager.py
Normal file
141
authentik/admin/files/manager.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http.request import HttpRequest
|
||||
from rest_framework.request import Request
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.backends.base import ManageableBackend
|
||||
from authentik.admin.files.backends.file import FileBackend
|
||||
from authentik.admin.files.backends.passthrough import PassthroughBackend
|
||||
from authentik.admin.files.backends.s3 import S3Backend
|
||||
from authentik.admin.files.backends.static import StaticBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
_FILE_BACKENDS = [
|
||||
StaticBackend,
|
||||
PassthroughBackend,
|
||||
FileBackend,
|
||||
S3Backend,
|
||||
]
|
||||
|
||||
|
||||
class FileManager:
|
||||
def __init__(self, usage: FileUsage) -> None:
|
||||
management_backend_name = CONFIG.get(
|
||||
f"storage.{usage.value}.backend",
|
||||
CONFIG.get("storage.backend", "file"),
|
||||
)
|
||||
|
||||
self.management_backend = None
|
||||
for backend in _FILE_BACKENDS:
|
||||
if issubclass(backend, ManageableBackend) and backend.name == management_backend_name:
|
||||
self.management_backend = backend(usage)
|
||||
if self.management_backend is None:
|
||||
LOGGER.warning(
|
||||
f"Storage backend configuration for {usage.value} is "
|
||||
f"invalid: {management_backend_name}"
|
||||
)
|
||||
|
||||
self.backends = []
|
||||
for backend in _FILE_BACKENDS:
|
||||
if usage not in backend.allowed_usages:
|
||||
continue
|
||||
if isinstance(self.management_backend, backend):
|
||||
self.backends.append(self.management_backend)
|
||||
elif not issubclass(backend, ManageableBackend):
|
||||
self.backends.append(backend(usage))
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
"""
|
||||
Whether this file manager is able to manage files.
|
||||
"""
|
||||
return self.management_backend is not None and self.management_backend.manageable
|
||||
|
||||
def list_files(self, manageable_only: bool = False) -> Generator[str]:
|
||||
"""
|
||||
List available files.
|
||||
"""
|
||||
for backend in self.backends:
|
||||
if manageable_only and not isinstance(backend, ManageableBackend):
|
||||
continue
|
||||
yield from backend.list_files()
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str | None,
|
||||
request: HttpRequest | Request | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get URL for accessing the file.
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
if isinstance(request, Request):
|
||||
request = request._request
|
||||
|
||||
for backend in self.backends:
|
||||
if backend.supports_file(name):
|
||||
return backend.file_url(name, request)
|
||||
|
||||
LOGGER.warning(f"Could not find file backend for file: {name}")
|
||||
return ""
|
||||
|
||||
def _check_manageable(self) -> None:
|
||||
if not self.manageable:
|
||||
raise ImproperlyConfigured("No file management backend configured.")
|
||||
|
||||
def save_file(self, file_path: str, content: bytes) -> None:
|
||||
"""
|
||||
Save file contents to storage.
|
||||
"""
|
||||
self._check_manageable()
|
||||
assert self.management_backend is not None # nosec
|
||||
return self.management_backend.save_file(file_path, content)
|
||||
|
||||
def save_file_stream(self, file_path: str) -> Iterator:
|
||||
"""
|
||||
Context manager for streaming file writes.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
|
||||
Returns:
|
||||
Context manager that yields a writable file-like object
|
||||
|
||||
Usage:
|
||||
with manager.save_file_stream("output.csv") as f:
|
||||
f.write(b"data...")
|
||||
"""
|
||||
self._check_manageable()
|
||||
assert self.management_backend is not None # nosec
|
||||
return self.management_backend.save_file_stream(file_path)
|
||||
|
||||
def delete_file(self, file_path: str) -> None:
|
||||
"""
|
||||
Delete file from storage.
|
||||
"""
|
||||
self._check_manageable()
|
||||
assert self.management_backend is not None # nosec
|
||||
return self.management_backend.delete_file(file_path)
|
||||
|
||||
def file_exists(self, file_path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists.
|
||||
"""
|
||||
self._check_manageable()
|
||||
assert self.management_backend is not None # nosec
|
||||
return self.management_backend.file_exists(file_path)
|
||||
|
||||
|
||||
MANAGERS = {usage: FileManager(usage) for usage in list(FileUsage)}
|
||||
|
||||
|
||||
def get_file_manager(usage: FileUsage) -> FileManager:
|
||||
return MANAGERS[usage]
|
||||
1
authentik/admin/files/tests/__init__.py
Normal file
1
authentik/admin/files/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""authentik files tests"""
|
||||
229
authentik/admin/files/tests/test_api.py
Normal file
229
authentik/admin/files/tests/test_api.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""test file api"""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.admin.files.api import get_mime_from_filename
|
||||
from authentik.admin.files.manager import FileManager
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
class TestFileAPI(FileTestFileBackendMixin, TestCase):
|
||||
"""test file api"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_upload_creates_event(self):
|
||||
"""Test that uploading a file creates a FILE_UPLOADED event"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_content = b"test file content"
|
||||
file_name = "test-upload.png"
|
||||
|
||||
# Upload file
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:files"),
|
||||
{
|
||||
"file": BytesIO(file_content),
|
||||
"name": file_name,
|
||||
"usage": FileUsage.MEDIA.value,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify event was created
|
||||
event = Event.objects.filter(action=EventAction.MODEL_CREATED).first()
|
||||
|
||||
self.assertIsNotNone(event)
|
||||
assert event is not None # nosec
|
||||
self.assertEqual(event.context["model"]["name"], file_name)
|
||||
self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value)
|
||||
self.assertEqual(event.context["model"]["mime_type"], "image/png")
|
||||
|
||||
# Verify user is captured
|
||||
self.assertEqual(event.user["username"], self.user.username)
|
||||
self.assertEqual(event.user["pk"], self.user.pk)
|
||||
|
||||
manager.delete_file(file_name)
|
||||
|
||||
def test_delete_creates_event(self):
|
||||
"""Test that deleting a file creates an event"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_name = "test-delete.png"
|
||||
manager.save_file(file_name, b"test content")
|
||||
|
||||
# Delete file
|
||||
response = self.client.delete(
|
||||
reverse(
|
||||
"authentik_api:files",
|
||||
query={
|
||||
"name": file_name,
|
||||
"usage": FileUsage.MEDIA.value,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Verify event was created
|
||||
event = Event.objects.filter(action=EventAction.MODEL_DELETED).first()
|
||||
|
||||
self.assertIsNotNone(event)
|
||||
assert event is not None # nosec
|
||||
self.assertEqual(event.context["model"]["name"], file_name)
|
||||
self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value)
|
||||
|
||||
# Verify user is captured
|
||||
self.assertEqual(event.user["username"], self.user.username)
|
||||
self.assertEqual(event.user["pk"], self.user.pk)
|
||||
|
||||
def test_list_files_basic(self):
|
||||
"""Test listing files with default parameters"""
|
||||
response = self.client.get(reverse("authentik_api:files"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "/static/authentik/sources/ldap.png",
|
||||
"url": "/static/authentik/sources/ldap.png",
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
response.data,
|
||||
)
|
||||
|
||||
def test_list_files_invalid_usage(self):
|
||||
"""Test listing files with invalid usage parameter"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:files",
|
||||
query={
|
||||
"usage": "invalid",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("not a valid choice", str(response.data))
|
||||
|
||||
def test_list_files_with_search(self):
|
||||
"""Test listing files with search query"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:files",
|
||||
query={
|
||||
"search": "ldap.png",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "/static/authentik/sources/ldap.png",
|
||||
"url": "/static/authentik/sources/ldap.png",
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
response.data,
|
||||
)
|
||||
|
||||
def test_list_files_with_manageable_only(self):
|
||||
"""Test listing files with omit parameter"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:files",
|
||||
query={
|
||||
"manageableOnly": "true",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(
|
||||
{
|
||||
"name": "/static/dist/assets/images/flow_background.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
},
|
||||
response.data,
|
||||
)
|
||||
|
||||
def test_upload_file_with_custom_path(self):
|
||||
"""Test uploading file with custom path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_name = "custom/test"
|
||||
file_content = b"test content"
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:files"),
|
||||
{
|
||||
"file": BytesIO(file_content),
|
||||
"name": file_name,
|
||||
"usage": FileUsage.MEDIA.value,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(manager.file_exists(file_name))
|
||||
manager.delete_file(file_name)
|
||||
|
||||
def test_upload_file_duplicate(self):
|
||||
"""Test uploading file that already exists"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
file_name = "test-file.png"
|
||||
file_content = b"test content"
|
||||
manager.save_file(file_name, file_content)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:files"),
|
||||
{
|
||||
"file": BytesIO(file_content),
|
||||
"name": file_name,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("already exists", str(response.data))
|
||||
manager.delete_file(file_name)
|
||||
|
||||
def test_delete_without_name_parameter(self):
|
||||
"""Test delete without name parameter"""
|
||||
response = self.client.delete(reverse("authentik_api:files"))
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("field is required", str(response.data))
|
||||
|
||||
|
||||
class TestGetMimeFromFilename(TestCase):
|
||||
"""Test get_mime_from_filename function"""
|
||||
|
||||
def test_image_png(self):
|
||||
"""Test PNG image MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.png"), "image/png")
|
||||
|
||||
def test_image_jpeg(self):
|
||||
"""Test JPEG image MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.jpg"), "image/jpeg")
|
||||
|
||||
def test_image_svg(self):
|
||||
"""Test SVG image MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.svg"), "image/svg+xml")
|
||||
|
||||
def test_text_plain(self):
|
||||
"""Test text file MIME type"""
|
||||
self.assertEqual(get_mime_from_filename("test.txt"), "text/plain")
|
||||
|
||||
def test_unknown_extension(self):
|
||||
"""Test unknown extension returns octet-stream"""
|
||||
self.assertEqual(get_mime_from_filename("test.unknown"), "application/octet-stream")
|
||||
|
||||
def test_no_extension(self):
|
||||
"""Test no extension returns octet-stream"""
|
||||
self.assertEqual(get_mime_from_filename("test"), "application/octet-stream")
|
||||
99
authentik/admin/files/tests/test_manager.py
Normal file
99
authentik/admin/files/tests/test_manager.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Test file service layer"""
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.manager import FileManager
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin, FileTestS3BackendMixin
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class TestResolveFileUrlBasic(TestCase):
|
||||
def test_resolve_empty_path(self):
|
||||
"""Test resolving empty file path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("")
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_resolve_none_path(self):
|
||||
"""Test resolving None file path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url(None)
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_resolve_font_awesome(self):
|
||||
"""Test resolving Font Awesome icon"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("fa://fa-check")
|
||||
self.assertEqual(result, "fa://fa-check")
|
||||
|
||||
def test_resolve_http_url(self):
|
||||
"""Test resolving HTTP URL"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("http://example.com/icon.png")
|
||||
self.assertEqual(result, "http://example.com/icon.png")
|
||||
|
||||
def test_resolve_https_url(self):
|
||||
"""Test resolving HTTPS URL"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("https://example.com/icon.png")
|
||||
self.assertEqual(result, "https://example.com/icon.png")
|
||||
|
||||
def test_resolve_static_path(self):
|
||||
"""Test resolving static file path"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("/static/authentik/sources/icon.svg")
|
||||
self.assertEqual(result, "/static/authentik/sources/icon.svg")
|
||||
|
||||
|
||||
class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
|
||||
def test_resolve_storage_file(self):
|
||||
"""Test resolving uploaded storage file"""
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("test.png").split("?")[0]
|
||||
self.assertEqual(result, "/files/media/public/test.png")
|
||||
|
||||
def test_resolve_full_static_with_request(self):
|
||||
"""Test resolving static file with request builds absolute URI"""
|
||||
mock_request = HttpRequest()
|
||||
mock_request.META = {
|
||||
"HTTP_HOST": "example.com",
|
||||
"SERVER_NAME": "example.com",
|
||||
}
|
||||
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("/static/icon.svg", mock_request)
|
||||
|
||||
self.assertEqual(result, "http://example.com/static/icon.svg")
|
||||
|
||||
def test_resolve_full_file_backend_with_request(self):
|
||||
"""Test resolving FileBackend file with request"""
|
||||
mock_request = HttpRequest()
|
||||
mock_request.META = {
|
||||
"HTTP_HOST": "example.com",
|
||||
"SERVER_NAME": "example.com",
|
||||
}
|
||||
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("test.png", mock_request).split("?")[0]
|
||||
|
||||
self.assertEqual(result, "http://example.com/files/media/public/test.png")
|
||||
|
||||
|
||||
class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
@CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test")
|
||||
@CONFIG.patch("storage.media.s3.secure_urls", False)
|
||||
def test_resolve_full_s3_backend(self):
|
||||
"""Test resolving S3Backend returns presigned URL as-is"""
|
||||
mock_request = HttpRequest()
|
||||
mock_request.META = {
|
||||
"HTTP_HOST": "example.com",
|
||||
"SERVER_NAME": "example.com",
|
||||
}
|
||||
|
||||
manager = FileManager(FileUsage.MEDIA)
|
||||
result = manager.file_url("test.png", mock_request)
|
||||
|
||||
# S3 URLs should be returned as-is (already absolute)
|
||||
self.assertTrue(result.startswith("http://s3.test:8080/test"))
|
||||
110
authentik/admin/files/tests/test_validation.py
Normal file
110
authentik/admin/files/tests/test_validation.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.validation import (
|
||||
MAX_FILE_NAME_LENGTH,
|
||||
MAX_PATH_COMPONENT_LENGTH,
|
||||
validate_file_name,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeFilePath(TestCase):
|
||||
"""Test validate_file_name function"""
|
||||
|
||||
def test_sanitize_valid_filename(self):
|
||||
"""Test sanitizing valid filename"""
|
||||
validate_file_name("test.png")
|
||||
|
||||
def test_sanitize_valid_path_with_directory(self):
|
||||
"""Test sanitizing valid path with directory"""
|
||||
validate_file_name("images/test.png")
|
||||
|
||||
def test_sanitize_valid_path_with_nested_dirs(self):
|
||||
"""Test sanitizing valid path with nested directories"""
|
||||
validate_file_name("dir1/dir2/dir3/test.png")
|
||||
|
||||
def test_sanitize_with_hyphens(self):
|
||||
"""Test sanitizing filename with hyphens"""
|
||||
validate_file_name("test-file-name.png")
|
||||
|
||||
def test_sanitize_with_underscores(self):
|
||||
"""Test sanitizing filename with underscores"""
|
||||
validate_file_name("test_file_name.png")
|
||||
|
||||
def test_sanitize_with_dots(self):
|
||||
"""Test sanitizing filename with multiple dots"""
|
||||
validate_file_name("test.file.name.png")
|
||||
|
||||
def test_sanitize_strips_whitespace(self):
|
||||
"""Test sanitizing filename strips whitespace"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(" test.png ")
|
||||
|
||||
def test_sanitize_removes_duplicate_slashes(self):
|
||||
"""Test sanitizing path removes duplicate slashes"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("dir1//dir2///test.png")
|
||||
|
||||
def test_sanitize_empty_path_raises(self):
|
||||
"""Test sanitizing empty path raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("")
|
||||
|
||||
def test_sanitize_whitespace_only_raises(self):
|
||||
"""Test sanitizing whitespace-only path raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(" ")
|
||||
|
||||
def test_sanitize_invalid_characters_raises(self):
|
||||
"""Test sanitizing path with invalid characters raises ValidationError"""
|
||||
invalid_paths = [
|
||||
"test file.png", # space
|
||||
"test@file.png", # @
|
||||
"test#file.png", # #
|
||||
"test$file.png", # $
|
||||
"test%file.png", # %
|
||||
"test&file.png", # &
|
||||
"test*file.png", # *
|
||||
"test(file).png", # parentheses
|
||||
"test[file].png", # brackets
|
||||
"test{file}.png", # braces
|
||||
]
|
||||
|
||||
for path in invalid_paths:
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
|
||||
def test_sanitize_absolute_path_raises(self):
|
||||
"""Test sanitizing absolute path raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("/absolute/path/test.png")
|
||||
|
||||
def test_sanitize_parent_directory_raises(self):
|
||||
"""Test sanitizing path with parent directory reference raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("../test.png")
|
||||
|
||||
def test_sanitize_nested_parent_directory_raises(self):
|
||||
"""Test sanitizing path with nested parent directory reference raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name("dir1/../test.png")
|
||||
|
||||
def test_sanitize_starts_with_dot_raises(self):
|
||||
"""Test sanitizing path starting with dot raises ValidationError"""
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(".hidden")
|
||||
|
||||
def test_sanitize_too_long_path_raises(self):
|
||||
"""Test sanitizing too long path raises ValidationError"""
|
||||
long_path = "a" * (MAX_FILE_NAME_LENGTH + 1) + ".png"
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(long_path)
|
||||
|
||||
def test_sanitize_too_long_component_raises(self):
|
||||
"""Test sanitizing path with too long component raises ValidationError"""
|
||||
long_component = "a" * (MAX_PATH_COMPONENT_LENGTH + 1)
|
||||
path = f"dir/{long_component}.png"
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
114
authentik/admin/files/tests/utils.py
Normal file
114
authentik/admin/files/tests/utils.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import shutil
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from authentik.admin.files.backends.s3 import S3Backend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG, UNSET
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class FileTestFileBackendMixin:
|
||||
def setUp(self):
|
||||
self.original_media_backend = CONFIG.get("storage.media.backend", UNSET)
|
||||
self.original_media_backend_path = CONFIG.get("storage.media.file.path", UNSET)
|
||||
self.media_backend_path = mkdtemp()
|
||||
CONFIG.set("storage.media.backend", "file")
|
||||
CONFIG.set("storage.media.file.path", str(self.media_backend_path))
|
||||
|
||||
self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET)
|
||||
self.original_reports_backend_path = CONFIG.get("storage.reports.file.path", UNSET)
|
||||
self.reports_backend_path = mkdtemp()
|
||||
CONFIG.set("storage.reports.backend", "file")
|
||||
CONFIG.set("storage.reports.file.path", str(self.reports_backend_path))
|
||||
|
||||
def tearDown(self):
|
||||
if self.original_media_backend is not UNSET:
|
||||
CONFIG.set("storage.media.backend", self.original_media_backend)
|
||||
else:
|
||||
CONFIG.delete("storage.media.backend")
|
||||
if self.original_media_backend_path is not UNSET:
|
||||
CONFIG.set("storage.media.file.path", self.original_media_backend_path)
|
||||
else:
|
||||
CONFIG.delete("storage.media.file.path")
|
||||
shutil.rmtree(self.media_backend_path)
|
||||
|
||||
if self.original_reports_backend is not UNSET:
|
||||
CONFIG.set("storage.reports.backend", self.original_reports_backend)
|
||||
else:
|
||||
CONFIG.delete("storage.reports.backend")
|
||||
if self.original_reports_backend_path is not UNSET:
|
||||
CONFIG.set("storage.reports.file.path", self.original_reports_backend_path)
|
||||
else:
|
||||
CONFIG.delete("storage.reports.file.path")
|
||||
shutil.rmtree(self.reports_backend_path)
|
||||
|
||||
|
||||
class FileTestS3BackendMixin:
|
||||
def setUp(self):
|
||||
s3_config_keys = {
|
||||
"endpoint",
|
||||
"access_key",
|
||||
"secret_key",
|
||||
"bucket_name",
|
||||
}
|
||||
self.original_media_backend = CONFIG.get("storage.media.backend", UNSET)
|
||||
CONFIG.set("storage.media.backend", "s3")
|
||||
self.original_media_s3_settings = {}
|
||||
for key in s3_config_keys:
|
||||
self.original_media_s3_settings[key] = CONFIG.get(f"storage.media.s3.{key}", UNSET)
|
||||
self.media_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.media.s3.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.media.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
|
||||
self.media_s3_backend = S3Backend(FileUsage.MEDIA)
|
||||
self.media_s3_backend.client.create_bucket(Bucket=self.media_s3_bucket_name, ACL="private")
|
||||
|
||||
self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET)
|
||||
CONFIG.set("storage.reports.backend", "s3")
|
||||
self.original_reports_s3_settings = {}
|
||||
for key in s3_config_keys:
|
||||
self.original_reports_s3_settings[key] = CONFIG.get(f"storage.reports.s3.{key}", UNSET)
|
||||
self.reports_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.reports.s3.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.reports.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)
|
||||
self.reports_s3_backend = S3Backend(FileUsage.REPORTS)
|
||||
self.reports_s3_backend.client.create_bucket(
|
||||
Bucket=self.reports_s3_bucket_name, ACL="private"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
def delete_objects_in_bucket(client, bucket_name):
|
||||
paginator = client.get_paginator("list_objects_v2")
|
||||
pages = paginator.paginate(Bucket=bucket_name)
|
||||
for page in pages:
|
||||
if "Contents" not in page:
|
||||
continue
|
||||
for obj in page["Contents"]:
|
||||
client.delete_object(Bucket=bucket_name, Key=obj["Key"])
|
||||
|
||||
delete_objects_in_bucket(self.media_s3_backend.client, self.media_s3_bucket_name)
|
||||
self.media_s3_backend.client.delete_bucket(Bucket=self.media_s3_bucket_name)
|
||||
if self.original_media_backend is not UNSET:
|
||||
CONFIG.set("storage.media.backend", self.original_media_backend)
|
||||
else:
|
||||
CONFIG.delete("storage.media.backend")
|
||||
for k, v in self.original_media_s3_settings.items():
|
||||
if v is not UNSET:
|
||||
CONFIG.set(f"storage.media.s3.{k}", v)
|
||||
else:
|
||||
CONFIG.delete(f"storage.media.s3.{k}")
|
||||
|
||||
delete_objects_in_bucket(self.reports_s3_backend.client, self.reports_s3_bucket_name)
|
||||
self.reports_s3_backend.client.delete_bucket(Bucket=self.reports_s3_bucket_name)
|
||||
if self.original_reports_backend is not UNSET:
|
||||
CONFIG.set("storage.reports.backend", self.original_reports_backend)
|
||||
else:
|
||||
CONFIG.delete("storage.reports.backend")
|
||||
for k, v in self.original_reports_s3_settings.items():
|
||||
if v is not UNSET:
|
||||
CONFIG.set(f"storage.reports.s3.{k}", v)
|
||||
else:
|
||||
CONFIG.delete(f"storage.reports.s3.{k}")
|
||||
8
authentik/admin/files/urls.py
Normal file
8
authentik/admin/files/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from authentik.admin.files.api import FileUsedByView, FileView
|
||||
|
||||
api_urlpatterns = [
|
||||
path("admin/file/", FileView.as_view(), name="files"),
|
||||
path("admin/file/used_by/", FileUsedByView.as_view(), name="files-used-by"),
|
||||
]
|
||||
17
authentik/admin/files/usage.py
Normal file
17
authentik/admin/files/usage.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from enum import StrEnum
|
||||
from itertools import chain
|
||||
|
||||
|
||||
class FileApiUsage(StrEnum):
|
||||
"""Usage types for file API"""
|
||||
|
||||
MEDIA = "media"
|
||||
|
||||
|
||||
class FileManagedUsage(StrEnum):
|
||||
"""Usage types for managed files"""
|
||||
|
||||
REPORTS = "reports"
|
||||
|
||||
|
||||
FileUsage = StrEnum("FileUsage", [(v.name, v.value) for v in chain(FileApiUsage, FileManagedUsage)])
|
||||
79
authentik/admin/files/validation.py
Normal file
79
authentik/admin/files/validation.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from authentik.admin.files.backends.passthrough import PassthroughBackend
|
||||
from authentik.admin.files.backends.static import StaticBackend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
# File upload limits
|
||||
MAX_FILE_NAME_LENGTH = 1024
|
||||
MAX_PATH_COMPONENT_LENGTH = 255
|
||||
|
||||
|
||||
def validate_file_name(name: str) -> None:
|
||||
if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend(
|
||||
FileUsage.MEDIA
|
||||
).supports_file(name):
|
||||
return
|
||||
validate_upload_file_name(name)
|
||||
|
||||
|
||||
def validate_upload_file_name(
|
||||
name: str,
|
||||
ValidationError: type[Exception] = ValidationError,
|
||||
) -> None:
|
||||
"""Sanitize file path.
|
||||
|
||||
Args:
|
||||
file_path: The file path to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized file path
|
||||
|
||||
Raises:
|
||||
ValidationError: If file path is invalid
|
||||
"""
|
||||
if not name:
|
||||
raise ValidationError(_("File name cannot be empty"))
|
||||
|
||||
# Same regex is used in the frontend as well
|
||||
if not re.match(r"^[a-zA-Z0-9._/-]+$", name):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
|
||||
"dots (.), hyphens (-), underscores (_), and forward slashes (/)"
|
||||
)
|
||||
)
|
||||
|
||||
if "//" in name:
|
||||
raise ValidationError(_("File name cannot contain duplicate /"))
|
||||
|
||||
# Convert to posix path
|
||||
path = PurePosixPath(name)
|
||||
|
||||
# Check for absolute paths
|
||||
# Needs the / at the start. If it doesn't have it, it might still be unsafe, so see L53+
|
||||
if path.is_absolute():
|
||||
raise ValidationError(_("Absolute paths are not allowed"))
|
||||
|
||||
# Check for parent directory references
|
||||
if ".." in path.parts:
|
||||
raise ValidationError(_("Parent directory references ('..') are not allowed"))
|
||||
|
||||
# Disallow paths starting with dot (hidden files at root level)
|
||||
if str(path).startswith("."):
|
||||
raise ValidationError(_("Paths cannot start with '.'"))
|
||||
|
||||
# Check path length limits
|
||||
normalized = str(path)
|
||||
if len(normalized) > MAX_FILE_NAME_LENGTH:
|
||||
raise ValidationError(_(f"File name too long (max {MAX_FILE_NAME_LENGTH} characters)"))
|
||||
|
||||
for part in path.parts:
|
||||
if len(part) > MAX_PATH_COMPONENT_LENGTH:
|
||||
raise ValidationError(
|
||||
_(f"Path component too long (max {MAX_PATH_COMPONENT_LENGTH} characters)")
|
||||
)
|
||||
@@ -27,83 +27,21 @@ except OSError:
|
||||
ipc_key = None
|
||||
|
||||
|
||||
def validate_auth(header: bytes) -> str | None:
|
||||
def validate_auth(header: bytes, format="bearer") -> str | None:
|
||||
"""Validate that the header is in a correct format,
|
||||
returns type and credentials"""
|
||||
auth_credentials = header.decode().strip()
|
||||
if auth_credentials == "" or " " not in auth_credentials:
|
||||
return None
|
||||
auth_type, _, auth_credentials = auth_credentials.partition(" ")
|
||||
if auth_type.lower() != "bearer":
|
||||
if not compare_digest(auth_type.lower(), format):
|
||||
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
|
||||
raise AuthenticationFailed("Unsupported authentication type")
|
||||
return None
|
||||
if auth_credentials == "": # nosec # noqa
|
||||
raise AuthenticationFailed("Malformed header")
|
||||
return auth_credentials
|
||||
|
||||
|
||||
def bearer_auth(raw_header: bytes) -> User | None:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
user = auth_user_lookup(raw_header)
|
||||
if not user:
|
||||
return None
|
||||
if not user.is_active:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
return user
|
||||
|
||||
|
||||
def auth_user_lookup(raw_header: bytes) -> User | None:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
from authentik.providers.oauth2.models import AccessToken
|
||||
|
||||
auth_credentials = validate_auth(raw_header)
|
||||
if not auth_credentials:
|
||||
return None
|
||||
# first, check traditional tokens
|
||||
key_token = Token.filter_not_expired(
|
||||
key=auth_credentials, intent=TokenIntents.INTENT_API
|
||||
).first()
|
||||
if key_token:
|
||||
CTX_AUTH_VIA.set("api_token")
|
||||
return key_token.user
|
||||
# then try to auth via JWT
|
||||
jwt_token = AccessToken.filter_not_expired(
|
||||
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||
).first()
|
||||
if jwt_token:
|
||||
# Double-check scopes, since they are saved in a single string
|
||||
# we want to check the parsed version too
|
||||
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
CTX_AUTH_VIA.set("jwt")
|
||||
return jwt_token.user
|
||||
# then try to auth via secret key (for embedded outpost/etc)
|
||||
user = token_secret_key(auth_credentials)
|
||||
if user:
|
||||
CTX_AUTH_VIA.set("secret_key")
|
||||
return user
|
||||
# then try to auth via secret key (for embedded outpost/etc)
|
||||
user = token_ipc(auth_credentials)
|
||||
if user:
|
||||
CTX_AUTH_VIA.set("ipc")
|
||||
return user
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
|
||||
|
||||
def token_secret_key(value: str) -> User | None:
|
||||
"""Check if the token is the secret key
|
||||
and return the service account for the managed outpost"""
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
|
||||
if not compare_digest(value, settings.SECRET_KEY):
|
||||
return None
|
||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||
if not outposts:
|
||||
return None
|
||||
outpost = outposts.first()
|
||||
return outpost.user
|
||||
|
||||
|
||||
class IPCUser(AnonymousUser):
|
||||
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
|
||||
|
||||
@@ -133,14 +71,6 @@ class IPCUser(AnonymousUser):
|
||||
return True
|
||||
|
||||
|
||||
def token_ipc(value: str) -> User | None:
|
||||
"""Check if the token is the secret key
|
||||
and return the service account for the managed outpost"""
|
||||
if not ipc_key or not compare_digest(value, ipc_key):
|
||||
return None
|
||||
return IPCUser()
|
||||
|
||||
|
||||
class TokenAuthentication(BaseAuthentication):
|
||||
"""Token-based authentication using HTTP Bearer authentication"""
|
||||
|
||||
@@ -148,12 +78,79 @@ class TokenAuthentication(BaseAuthentication):
|
||||
"""Token-based authentication using HTTP Bearer authentication"""
|
||||
auth = get_authorization_header(request)
|
||||
|
||||
user = bearer_auth(auth)
|
||||
user_ctx = self.bearer_auth(auth)
|
||||
# None is only returned when the header isn't set.
|
||||
if not user:
|
||||
if not user_ctx:
|
||||
return None
|
||||
|
||||
return (user, None) # pragma: no cover
|
||||
return user_ctx
|
||||
|
||||
def bearer_auth(self, raw_header: bytes) -> tuple[User, Any] | None:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
user_ctx = self.auth_user_lookup(raw_header)
|
||||
if not user_ctx:
|
||||
return None
|
||||
user, ctx = user_ctx
|
||||
if not user.is_active:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
return user, ctx
|
||||
|
||||
def auth_user_lookup(self, raw_header: bytes) -> tuple[User, Any] | None:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
from authentik.providers.oauth2.models import AccessToken
|
||||
|
||||
auth_credentials = validate_auth(raw_header)
|
||||
if not auth_credentials:
|
||||
return None
|
||||
# first, check traditional tokens
|
||||
key_token = Token.filter_not_expired(
|
||||
key=auth_credentials, intent=TokenIntents.INTENT_API
|
||||
).first()
|
||||
if key_token:
|
||||
CTX_AUTH_VIA.set("api_token")
|
||||
return key_token.user, key_token
|
||||
# then try to auth via JWT
|
||||
jwt_token = AccessToken.filter_not_expired(
|
||||
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||
).first()
|
||||
if jwt_token:
|
||||
# Double-check scopes, since they are saved in a single string
|
||||
# we want to check the parsed version too
|
||||
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
CTX_AUTH_VIA.set("jwt")
|
||||
return jwt_token.user, jwt_token
|
||||
# then try to auth via secret key (for embedded outpost/etc)
|
||||
user_outpost = self.token_secret_key(auth_credentials)
|
||||
if user_outpost:
|
||||
CTX_AUTH_VIA.set("secret_key")
|
||||
return user_outpost
|
||||
# then try to auth via secret key (for embedded outpost/etc)
|
||||
user = self.token_ipc(auth_credentials)
|
||||
if user:
|
||||
CTX_AUTH_VIA.set("ipc")
|
||||
return user
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
|
||||
def token_ipc(self, value: str) -> tuple[User, None] | None:
|
||||
"""Check if the token is the secret key
|
||||
and return the service account for the managed outpost"""
|
||||
if not ipc_key or not compare_digest(value, ipc_key):
|
||||
return None
|
||||
return IPCUser(), None
|
||||
|
||||
def token_secret_key(self, value: str) -> tuple[User, Outpost] | None:
|
||||
"""Check if the token is the secret key
|
||||
and return the service account for the managed outpost"""
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
|
||||
if not compare_digest(value, settings.SECRET_KEY):
|
||||
return None
|
||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||
if not outposts:
|
||||
return None
|
||||
outpost = outposts.first()
|
||||
return outpost.user, outpost
|
||||
|
||||
|
||||
class TokenSchema(OpenApiAuthenticationExtension):
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import json
|
||||
from base64 import b64encode
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from authentik.api.authentication import bearer_auth
|
||||
from authentik.api.authentication import IPCUser, TokenAuthentication
|
||||
from authentik.blueprints.tests import reconcile_app
|
||||
from authentik.core.models import Token, TokenIntents, User, UserTypes
|
||||
from authentik.core.models import Token, TokenIntents, UserTypes
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
@@ -24,24 +25,24 @@ class TestAPIAuth(TestCase):
|
||||
|
||||
def test_invalid_type(self):
|
||||
"""Test invalid type"""
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
bearer_auth(b"foo bar")
|
||||
self.assertIsNone(TokenAuthentication().bearer_auth(b"foo bar"))
|
||||
|
||||
def test_invalid_empty(self):
|
||||
"""Test invalid type"""
|
||||
self.assertIsNone(bearer_auth(b"Bearer "))
|
||||
self.assertIsNone(bearer_auth(b""))
|
||||
self.assertIsNone(TokenAuthentication().bearer_auth(b"Bearer "))
|
||||
self.assertIsNone(TokenAuthentication().bearer_auth(b""))
|
||||
|
||||
def test_invalid_no_token(self):
|
||||
"""Test invalid with no token"""
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
auth = b64encode(b":abc").decode()
|
||||
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
|
||||
auth = b64encode(b":abc").decode()
|
||||
self.assertIsNone(TokenAuthentication().bearer_auth(f"Basic :{auth}".encode()))
|
||||
|
||||
def test_bearer_valid(self):
|
||||
"""Test valid token"""
|
||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=create_test_admin_user())
|
||||
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
||||
user, tk = TokenAuthentication().bearer_auth(f"Bearer {token.key}".encode())
|
||||
self.assertEqual(user, token.user)
|
||||
self.assertEqual(token, token)
|
||||
|
||||
def test_bearer_valid_deactivated(self):
|
||||
"""Test valid token"""
|
||||
@@ -50,7 +51,7 @@ class TestAPIAuth(TestCase):
|
||||
user.save()
|
||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=user)
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
bearer_auth(f"Bearer {token.key}".encode())
|
||||
TokenAuthentication().bearer_auth(f"Bearer {token.key}".encode())
|
||||
|
||||
@reconcile_app("authentik_outposts")
|
||||
def test_managed_outpost_fail(self):
|
||||
@@ -59,20 +60,21 @@ class TestAPIAuth(TestCase):
|
||||
outpost.user.delete()
|
||||
outpost.delete()
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||
TokenAuthentication().bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||
|
||||
@reconcile_app("authentik_outposts")
|
||||
def test_managed_outpost_success(self):
|
||||
"""Test managed outpost"""
|
||||
user: User = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||
user, outpost = TokenAuthentication().bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||
self.assertEqual(user.type, UserTypes.INTERNAL_SERVICE_ACCOUNT)
|
||||
self.assertEqual(outpost, Outpost.objects.filter(managed=MANAGED_OUTPOST).first())
|
||||
|
||||
def test_jwt_valid(self):
|
||||
"""Test valid JWT"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
refresh = AccessToken.objects.create(
|
||||
access = AccessToken.objects.create(
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
token=generate_id(),
|
||||
@@ -80,14 +82,16 @@ class TestAPIAuth(TestCase):
|
||||
_scope=SCOPE_AUTHENTIK_API,
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||
user, token = TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
|
||||
self.assertEqual(user, access.user)
|
||||
self.assertEqual(token, access)
|
||||
|
||||
def test_jwt_missing_scope(self):
|
||||
"""Test valid JWT"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
refresh = AccessToken.objects.create(
|
||||
access = AccessToken.objects.create(
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
token=generate_id(),
|
||||
@@ -96,4 +100,12 @@ class TestAPIAuth(TestCase):
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||
TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
|
||||
|
||||
def test_ipc(self):
|
||||
"""Test IPC auth (mock key)"""
|
||||
key = generate_id()
|
||||
with patch("authentik.api.authentication.ipc_key", key):
|
||||
user, ctx = TokenAuthentication().bearer_auth(f"Bearer {key}".encode())
|
||||
self.assertEqual(user, IPCUser())
|
||||
self.assertEqual(ctx, None)
|
||||
|
||||
62
authentik/api/tests/test_view_authn_authz.py
Normal file
62
authentik/api/tests/test_view_authn_authz.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from collections.abc import Callable
|
||||
from inspect import getmembers
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
|
||||
|
||||
class TestAPIViewAuthnAuthz(APITestCase): ...
|
||||
|
||||
|
||||
def api_viewset_action(viewset: GenericViewSet, member: Callable) -> Callable:
|
||||
"""Test API Viewset action"""
|
||||
|
||||
def tester(self: TestAPIViewAuthnAuthz):
|
||||
if "permission_classes" in member.kwargs:
|
||||
self.assertNotEqual(
|
||||
member.kwargs["permission_classes"], [], "permission_classes should not be empty"
|
||||
)
|
||||
if "authentication_classes" in member.kwargs:
|
||||
self.assertNotEqual(
|
||||
member.kwargs["authentication_classes"],
|
||||
[],
|
||||
"authentication_classes should not be empty",
|
||||
)
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
def api_view(view: APIView) -> Callable:
|
||||
|
||||
def tester(self: TestAPIViewAuthnAuthz):
|
||||
self.assertNotEqual(view.permission_classes, [], "permission_classes should not be empty")
|
||||
self.assertNotEqual(
|
||||
view.authentication_classes,
|
||||
[],
|
||||
"authentication_classes should not be empty",
|
||||
)
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
# Tell django to load all URLs
|
||||
reverse("authentik_core:root-redirect")
|
||||
for viewset in all_subclasses(GenericViewSet):
|
||||
for act_name, member in getmembers(viewset(), lambda x: isinstance(x, Callable)):
|
||||
if not hasattr(member, "kwargs") or not hasattr(member, "mapping"):
|
||||
continue
|
||||
setattr(
|
||||
TestAPIViewAuthnAuthz,
|
||||
f"test_viewset_{viewset.__name__}_action_{act_name}",
|
||||
api_viewset_action(viewset, member),
|
||||
)
|
||||
for view in all_subclasses(APIView):
|
||||
setattr(
|
||||
TestAPIViewAuthnAuthz,
|
||||
f"test_view_{view.__name__}",
|
||||
api_view(view),
|
||||
)
|
||||
@@ -1,10 +1,9 @@
|
||||
"""core Configs API"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.dispatch import Signal
|
||||
from django.http import HttpRequest
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.fields import (
|
||||
BooleanField,
|
||||
@@ -19,6 +18,8 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.events.context_processors.base import get_context_processors
|
||||
from authentik.lib.config import CONFIG
|
||||
@@ -63,31 +64,28 @@ class ConfigView(APIView):
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get_capabilities(self) -> list[Capabilities]:
|
||||
@staticmethod
|
||||
def get_capabilities(request: HttpRequest) -> list[Capabilities]:
|
||||
"""Get all capabilities this server instance supports"""
|
||||
caps = []
|
||||
deb_test = settings.DEBUG or settings.TEST
|
||||
if (
|
||||
CONFIG.get("storage.media.backend", "file") == "s3"
|
||||
or Path(settings.STORAGES["default"]["OPTIONS"]["location"]).is_mount()
|
||||
or deb_test
|
||||
):
|
||||
if get_file_manager(FileUsage.MEDIA).manageable:
|
||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||
for processor in get_context_processors():
|
||||
if cap := processor.capability():
|
||||
caps.append(cap)
|
||||
if self.request.tenant.impersonation:
|
||||
if request.tenant.impersonation:
|
||||
caps.append(Capabilities.CAN_IMPERSONATE)
|
||||
if settings.DEBUG: # pragma: no cover
|
||||
caps.append(Capabilities.CAN_DEBUG)
|
||||
if "authentik.enterprise" in settings.INSTALLED_APPS:
|
||||
caps.append(Capabilities.IS_ENTERPRISE)
|
||||
for _, result in capabilities.send(sender=self):
|
||||
for _, result in capabilities.send(sender=ConfigView):
|
||||
if result:
|
||||
caps.append(result)
|
||||
return caps
|
||||
|
||||
def get_config(self) -> ConfigSerializer:
|
||||
@staticmethod
|
||||
def get_config(request: HttpRequest) -> ConfigSerializer:
|
||||
"""Get Config"""
|
||||
return ConfigSerializer(
|
||||
{
|
||||
@@ -98,7 +96,7 @@ class ConfigView(APIView):
|
||||
"send_pii": CONFIG.get("error_reporting.send_pii"),
|
||||
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)),
|
||||
},
|
||||
"capabilities": self.get_capabilities(),
|
||||
"capabilities": ConfigView.get_capabilities(request),
|
||||
"cache_timeout": CONFIG.get_int("cache.timeout"),
|
||||
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"),
|
||||
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"),
|
||||
@@ -108,4 +106,4 @@ class ConfigView(APIView):
|
||||
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Retrieve public configuration options"""
|
||||
return Response(self.get_config().data)
|
||||
return Response(ConfigView.get_config(request).data)
|
||||
|
||||
50
authentik/api/validation.py
Normal file
50
authentik/api/validation.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Literal
|
||||
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import Serializer
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
|
||||
def validate(serializer_type: type[Serializer], location: Literal["body", "query"] = "body"):
|
||||
"""Validate incoming data with the specified serializer. Raw data can either be taken
|
||||
from request body or query string, defaulting to body.
|
||||
|
||||
Validated data is added to the function this decorator is used on with a named parameter
|
||||
based on the location of the data.
|
||||
|
||||
Example:
|
||||
|
||||
@validate(MySerializer)
|
||||
@validate(MyQuerySerializer, location="query")
|
||||
def my_action(self, request, *, body: MySerializer, query: MyQuerySerializer):
|
||||
...
|
||||
|
||||
"""
|
||||
|
||||
def wrapper_outer(func: Callable):
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self: ViewSet, request: Request, *args, **kwargs) -> Response:
|
||||
data = {}
|
||||
if location == "body":
|
||||
data = request.data
|
||||
elif location == "query":
|
||||
data = request.query_params
|
||||
else:
|
||||
raise ValueError(f"Invalid data location '{location}'")
|
||||
instance = serializer_type(
|
||||
data=data,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
instance.is_valid(raise_exception=True)
|
||||
kwargs[location] = instance
|
||||
return func(self, request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return wrapper_outer
|
||||
@@ -118,7 +118,10 @@ class Command(BaseCommand):
|
||||
model_instance: Model = model()
|
||||
if not isinstance(model_instance, SerializerModel):
|
||||
continue
|
||||
serializer_class = model_instance.serializer
|
||||
try:
|
||||
serializer_class = model_instance.serializer
|
||||
except NotImplementedError as exc:
|
||||
raise NotImplementedError(model_instance) from exc
|
||||
serializer = serializer_class(
|
||||
context={
|
||||
SERIALIZER_CONTEXT_BLUEPRINT: False,
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.core.models import Application, Token, User
|
||||
from authentik.core.models import Token, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
||||
@@ -29,24 +27,6 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
||||
self.assertIsNotNone(token)
|
||||
self.assertEqual(token.key, self.uid)
|
||||
|
||||
def test_application(self):
|
||||
"""Test application"""
|
||||
app = Application.objects.filter(slug=f"{self.uid}-app").first()
|
||||
self.assertIsNotNone(app)
|
||||
self.assertEqual(app.meta_icon, "https://goauthentik.io/img/icon.png")
|
||||
|
||||
def test_source(self):
|
||||
"""Test source"""
|
||||
source = OAuthSource.objects.filter(slug=f"{self.uid}-source").first()
|
||||
self.assertIsNotNone(source)
|
||||
self.assertEqual(source.icon, "https://goauthentik.io/img/icon.png")
|
||||
|
||||
def test_flow(self):
|
||||
"""Test flow"""
|
||||
flow = Flow.objects.filter(slug=f"{self.uid}-flow").first()
|
||||
self.assertIsNotNone(flow)
|
||||
self.assertEqual(flow.background, "https://goauthentik.io/img/icon.png")
|
||||
|
||||
def test_user(self):
|
||||
"""Test user"""
|
||||
user: User = User.objects.filter(username=self.uid).first()
|
||||
|
||||
@@ -42,6 +42,15 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
AgentDeviceConnection,
|
||||
AppleNonce,
|
||||
DeviceAuthenticationToken,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
DeviceToken as EndpointDeviceToken,
|
||||
)
|
||||
from authentik.endpoints.models import Connector, Device, DeviceConnection, DeviceFactSnapshot
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import LicenseUsage
|
||||
from authentik.enterprise.providers.google_workspace.models import (
|
||||
@@ -112,6 +121,7 @@ def excluded_models() -> list[type[Model]]:
|
||||
OutpostServiceConnection,
|
||||
Policy,
|
||||
PolicyBindingModel,
|
||||
Connector,
|
||||
# Classes that have other dependencies
|
||||
Session,
|
||||
AuthenticatedSession,
|
||||
@@ -139,6 +149,13 @@ def excluded_models() -> list[type[Model]]:
|
||||
MicrosoftEntraProviderGroup,
|
||||
EndpointDevice,
|
||||
EndpointDeviceConnection,
|
||||
EndpointDeviceToken,
|
||||
Device,
|
||||
DeviceConnection,
|
||||
DeviceAuthenticationToken,
|
||||
AppleNonce,
|
||||
AgentDeviceConnection,
|
||||
DeviceFactSnapshot,
|
||||
DeviceToken,
|
||||
StreamEvent,
|
||||
UserConsent,
|
||||
|
||||
@@ -163,4 +163,4 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
|
||||
def current(self, request: Request) -> Response:
|
||||
"""Get current brand"""
|
||||
brand: Brand = request._request.brand
|
||||
return Response(CurrentBrandSerializer(brand).data)
|
||||
return Response(CurrentBrandSerializer(brand, context={"request": request}).data)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 16:22
|
||||
|
||||
import authentik.admin.files.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_brands", "0010_brand_client_certificates_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="brand",
|
||||
name="branding_default_flow_background",
|
||||
field=authentik.admin.files.fields.FileField(
|
||||
default="/static/dist/assets/images/flow_background.jpg"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="brand",
|
||||
name="branding_favicon",
|
||||
field=authentik.admin.files.fields.FileField(
|
||||
default="/static/dist/assets/icons/icon.png"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="brand",
|
||||
name="branding_logo",
|
||||
field=authentik.admin.files.fields.FileField(
|
||||
default="/static/dist/assets/icons/icon_left_brand.svg"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -8,9 +8,11 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.fields import FileField
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -31,11 +33,11 @@ class Brand(SerializerModel):
|
||||
|
||||
branding_title = models.TextField(default="authentik")
|
||||
|
||||
branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg")
|
||||
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
|
||||
branding_logo = FileField(default="/static/dist/assets/icons/icon_left_brand.svg")
|
||||
branding_favicon = FileField(default="/static/dist/assets/icons/icon.png")
|
||||
branding_custom_css = models.TextField(default="", blank=True)
|
||||
branding_default_flow_background = models.TextField(
|
||||
default="/static/dist/assets/images/flow_background.jpg"
|
||||
branding_default_flow_background = FileField(
|
||||
default="/static/dist/assets/images/flow_background.jpg",
|
||||
)
|
||||
|
||||
flow_authentication = models.ForeignKey(
|
||||
@@ -84,25 +86,19 @@ class Brand(SerializerModel):
|
||||
attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
def branding_logo_url(self) -> str:
|
||||
"""Get branding_logo with the correct prefix"""
|
||||
if self.branding_logo.startswith("/static"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.branding_logo
|
||||
return self.branding_logo
|
||||
"""Get branding_logo URL"""
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_logo)
|
||||
|
||||
def branding_favicon_url(self) -> str:
|
||||
"""Get branding_favicon with the correct prefix"""
|
||||
if self.branding_favicon.startswith("/static"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.branding_favicon
|
||||
return self.branding_favicon
|
||||
"""Get branding_favicon URL"""
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_favicon)
|
||||
|
||||
def branding_default_flow_background_url(self) -> str:
|
||||
"""Get branding_default_flow_background with the correct prefix"""
|
||||
if self.branding_default_flow_background.startswith("/static"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.branding_default_flow_background
|
||||
return self.branding_default_flow_background
|
||||
"""Get branding_default_flow_background URL"""
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_default_flow_background)
|
||||
|
||||
@property
|
||||
def serializer(self) -> Serializer:
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.brands.api import BrandSerializer
|
||||
|
||||
return BrandSerializer
|
||||
|
||||
@@ -8,12 +8,11 @@ from django.db.models import QuerySet
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
@@ -26,16 +25,9 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||
from authentik.lib.utils.file import (
|
||||
FilePathSerializer,
|
||||
FileUploadSerializer,
|
||||
set_file,
|
||||
set_file_url,
|
||||
)
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.types import CACHE_PREFIX, PolicyResult
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -58,7 +50,7 @@ class ApplicationSerializer(ModelSerializer):
|
||||
source="backchannel_providers", required=False, read_only=True, many=True
|
||||
)
|
||||
|
||||
meta_icon = ReadOnlyField(source="get_meta_icon")
|
||||
meta_icon_url = ReadOnlyField(source="get_meta_icon")
|
||||
|
||||
def get_launch_url(self, app: Application) -> str | None:
|
||||
"""Allow formatting of launch URL"""
|
||||
@@ -95,13 +87,13 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"open_in_new_tab",
|
||||
"meta_launch_url",
|
||||
"meta_icon",
|
||||
"meta_icon_url",
|
||||
"meta_description",
|
||||
"meta_publisher",
|
||||
"policy_engine_mode",
|
||||
"group",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"meta_icon": {"read_only": True},
|
||||
"backchannel_providers": {"required": False},
|
||||
}
|
||||
|
||||
@@ -286,44 +278,3 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@permission_required("authentik_core.change_application")
|
||||
@extend_schema(
|
||||
request={
|
||||
"multipart/form-data": FileUploadSerializer,
|
||||
},
|
||||
responses={
|
||||
200: OpenApiResponse(description="Success"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
methods=["POST"],
|
||||
parser_classes=(MultiPartParser,),
|
||||
)
|
||||
def set_icon(self, request: Request, slug: str):
|
||||
"""Set application icon"""
|
||||
app: Application = self.get_object()
|
||||
return set_file(request, app, "meta_icon")
|
||||
|
||||
@permission_required("authentik_core.change_application")
|
||||
@extend_schema(
|
||||
request=FilePathSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(description="Success"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
methods=["POST"],
|
||||
)
|
||||
def set_icon_url(self, request: Request, slug: str):
|
||||
"""Set application icon (as URL)"""
|
||||
app: Application = self.get_object()
|
||||
return set_file_url(request, app, "meta_icon")
|
||||
|
||||
@@ -13,6 +13,7 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.users import ParamUserSerializer
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
|
||||
@@ -71,7 +72,7 @@ class AdminDeviceViewSet(ViewSet):
|
||||
"""Viewset for authenticator devices"""
|
||||
|
||||
serializer_class = DeviceSerializer
|
||||
permission_classes = []
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_devices(self, **kwargs):
|
||||
"""Get all devices in all child classes"""
|
||||
@@ -85,8 +86,7 @@ class AdminDeviceViewSet(ViewSet):
|
||||
parameters=[ParamUserSerializer],
|
||||
responses={200: DeviceSerializer(many=True)},
|
||||
)
|
||||
def list(self, request: Request) -> Response:
|
||||
@validate(ParamUserSerializer, "query")
|
||||
def list(self, request: Request, query: ParamUserSerializer) -> Response:
|
||||
"""Get all devices for current user"""
|
||||
args = ParamUserSerializer(data=request.query_params)
|
||||
args.is_valid(raise_exception=True)
|
||||
return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data)
|
||||
return Response(DeviceSerializer(self.get_devices(**query.validated_data), many=True).data)
|
||||
|
||||
@@ -14,17 +14,22 @@ from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
)
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ListSerializer, ValidationError
|
||||
from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.authentication import TokenAuthentication
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
@@ -227,6 +232,11 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
search_fields = ["name", "is_superuser"]
|
||||
filterset_class = GroupFilter
|
||||
ordering = ["name"]
|
||||
authentication_classes = [
|
||||
TokenAuthentication,
|
||||
SessionAuthentication,
|
||||
AgentAuth,
|
||||
]
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import BoolField, StrField
|
||||
@@ -287,15 +297,16 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
methods=["POST"],
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
)
|
||||
def add_user(self, request: Request, pk: str) -> Response:
|
||||
@validate(UserAccountSerializer)
|
||||
def add_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
|
||||
"""Add user to group"""
|
||||
group: Group = self.get_object()
|
||||
user: User = (
|
||||
get_objects_for_user(request.user, "authentik_core.view_user")
|
||||
.filter(
|
||||
pk=request.data.get("pk"),
|
||||
pk=body.validated_data.get("pk"),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -317,15 +328,16 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
methods=["POST"],
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
)
|
||||
def remove_user(self, request: Request, pk: str) -> Response:
|
||||
@validate(UserAccountSerializer)
|
||||
def remove_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
|
||||
"""Remove user from group"""
|
||||
group: Group = self.get_object()
|
||||
user: User = (
|
||||
get_objects_for_user(request.user, "authentik_core.view_user")
|
||||
.filter(
|
||||
pk=request.data.get("pk"),
|
||||
pk=body.validated_data.get("pk"),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from rest_framework.response import Response
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.enterprise.apps import EnterpriseConfig
|
||||
from authentik.lib.models import DeprecatedMixin
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
|
||||
|
||||
@@ -24,6 +25,7 @@ class TypeCreateSerializer(PassiveSerializer):
|
||||
|
||||
icon_url = CharField(required=False)
|
||||
requires_enterprise = BooleanField(default=False)
|
||||
deprecated = BooleanField(default=False)
|
||||
|
||||
|
||||
class CreatableType:
|
||||
@@ -69,6 +71,7 @@ class TypesMixin:
|
||||
"requires_enterprise": isinstance(
|
||||
subclass._meta.app_config, EnterpriseConfig
|
||||
),
|
||||
"deprecated": isinstance(instance, DeprecatedMixin),
|
||||
}
|
||||
)
|
||||
except NotImplementedError:
|
||||
|
||||
@@ -21,6 +21,7 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.api import ManagedSerializer
|
||||
from authentik.core.api.object_types import TypesMixin
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@@ -128,23 +129,20 @@ class PropertyMappingViewSet(
|
||||
],
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||
def test(self, request: Request, pk: str) -> Response:
|
||||
@validate(PropertyMappingTestSerializer)
|
||||
def test(self, request: Request, pk: str, body: PropertyMappingTestSerializer) -> Response:
|
||||
"""Test Property Mapping"""
|
||||
_mapping: PropertyMapping = self.get_object()
|
||||
# Use `get_subclass` to get correct class and correct `.evaluate` implementation
|
||||
mapping: PropertyMapping = PropertyMapping.objects.get_subclass(pk=_mapping.pk)
|
||||
# FIXME: when we separate policy mappings between ones for sources
|
||||
# and ones for providers, we need to make the user field optional for the source mapping
|
||||
test_params = self.PropertyMappingTestSerializer(data=request.data)
|
||||
if not test_params.is_valid():
|
||||
return Response(test_params.errors, status=400)
|
||||
|
||||
format_result = str(request.GET.get("format_result", "false")).lower() == "true"
|
||||
|
||||
context: dict = test_params.validated_data.get("context", {})
|
||||
context: dict = body.validated_data.get("context", {})
|
||||
context.setdefault("user", None)
|
||||
|
||||
if user := test_params.validated_data.get("user"):
|
||||
if user := body.validated_data.get("user"):
|
||||
# User permission check, only allow mapping testing for users that are readable
|
||||
users = get_objects_for_user(request.user, "authentik_core.view_user").filter(
|
||||
pk=user.pk
|
||||
@@ -152,7 +150,7 @@ class PropertyMappingViewSet(
|
||||
if not users.exists():
|
||||
raise PermissionDenied()
|
||||
context["user"] = user
|
||||
if group := test_params.validated_data.get("group"):
|
||||
if group := body.validated_data.get("group"):
|
||||
# Group permission check, only allow mapping testing for groups that are readable
|
||||
groups = get_objects_for_user(request.user, "authentik_core.view_group").filter(
|
||||
pk=group.pk
|
||||
|
||||
@@ -2,31 +2,22 @@
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.fields import ReadOnlyField, SerializerMethodField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.object_types import TypesMixin
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
|
||||
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.lib.utils.file import (
|
||||
FilePathSerializer,
|
||||
FileUploadSerializer,
|
||||
set_file,
|
||||
set_file_url,
|
||||
)
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -36,7 +27,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
|
||||
managed = ReadOnlyField()
|
||||
component = SerializerMethodField()
|
||||
icon = ReadOnlyField(source="icon_url")
|
||||
icon_url = ReadOnlyField()
|
||||
|
||||
def get_component(self, obj: Source) -> str:
|
||||
"""Get object component so that we know how to edit the object"""
|
||||
@@ -44,11 +35,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
return ""
|
||||
return obj.component
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
self.fields["icon"] = CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Source
|
||||
fields = [
|
||||
@@ -56,6 +42,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"name",
|
||||
"slug",
|
||||
"enabled",
|
||||
"promoted",
|
||||
"authentication_flow",
|
||||
"enrollment_flow",
|
||||
"user_property_mappings",
|
||||
@@ -69,6 +56,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"managed",
|
||||
"user_path_template",
|
||||
"icon",
|
||||
"icon_url",
|
||||
]
|
||||
|
||||
|
||||
@@ -91,47 +79,6 @@ class SourceViewSet(
|
||||
def get_queryset(self): # pragma: no cover
|
||||
return Source.objects.select_subclasses()
|
||||
|
||||
@permission_required("authentik_core.change_source")
|
||||
@extend_schema(
|
||||
request={
|
||||
"multipart/form-data": FileUploadSerializer,
|
||||
},
|
||||
responses={
|
||||
200: OpenApiResponse(description="Success"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
methods=["POST"],
|
||||
parser_classes=(MultiPartParser,),
|
||||
)
|
||||
def set_icon(self, request: Request, slug: str):
|
||||
"""Set source icon"""
|
||||
source: Source = self.get_object()
|
||||
return set_file(request, source, "icon")
|
||||
|
||||
@permission_required("authentik_core.change_source")
|
||||
@extend_schema(
|
||||
request=FilePathSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(description="Success"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
methods=["POST"],
|
||||
)
|
||||
def set_icon_url(self, request: Request, slug: str):
|
||||
"""Set source icon (as URL)"""
|
||||
source: Source = self.get_object()
|
||||
return set_file_url(request, source, "icon")
|
||||
|
||||
@extend_schema(responses={200: UserSettingSerializer(many=True)})
|
||||
@action(detail=False, pagination_class=None, filter_backends=[])
|
||||
def user_settings(self, request: Request) -> Response:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Any
|
||||
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import assign_perm, get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
@@ -12,6 +12,7 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.api import ManagedSerializer
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@@ -107,6 +108,12 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
|
||||
}
|
||||
|
||||
|
||||
class TokenSetKeySerializer(PassiveSerializer):
|
||||
"""Set token's key"""
|
||||
|
||||
key = CharField()
|
||||
|
||||
|
||||
class TokenViewSerializer(PassiveSerializer):
|
||||
"""Show token's current key"""
|
||||
|
||||
@@ -170,12 +177,7 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@permission_required("authentik_core.set_token_key")
|
||||
@extend_schema(
|
||||
request=inline_serializer(
|
||||
"TokenSetKey",
|
||||
{
|
||||
"key": CharField(),
|
||||
},
|
||||
),
|
||||
request=TokenSetKeySerializer(),
|
||||
responses={
|
||||
204: OpenApiResponse(description="Successfully changed key"),
|
||||
400: OpenApiResponse(description="Missing key"),
|
||||
@@ -183,11 +185,12 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||
def set_key(self, request: Request, identifier: str) -> Response:
|
||||
@validate(TokenSetKeySerializer)
|
||||
def set_key(self, request: Request, identifier: str, body: TokenSetKeySerializer) -> Response:
|
||||
"""Set token key. Action is logged as event. `authentik_core.set_token_key` permission
|
||||
is required."""
|
||||
token: Token = self.get_object()
|
||||
key = request.data.get("key")
|
||||
key = body.validated_data.get("key")
|
||||
if not key:
|
||||
return Response(status=400)
|
||||
token.key = key
|
||||
|
||||
@@ -12,6 +12,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from yaml import ScalarNode
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.v1.common import (
|
||||
Blueprint,
|
||||
BlueprintEntry,
|
||||
@@ -160,11 +161,10 @@ class TransactionalApplicationView(APIView):
|
||||
200: TransactionApplicationResponseSerializer(),
|
||||
},
|
||||
)
|
||||
def put(self, request: Request) -> Response:
|
||||
@validate(TransactionApplicationSerializer)
|
||||
def put(self, request: Request, body: TransactionApplicationSerializer) -> Response:
|
||||
"""Convert data into a blueprint, validate it and apply it"""
|
||||
data = TransactionApplicationSerializer(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
blueprint: Blueprint = data.validated_data
|
||||
blueprint: Blueprint = body.validated_data
|
||||
for entry in blueprint.entries:
|
||||
full_model = entry.get_model(blueprint)
|
||||
app, __, model = full_model.partition(".")
|
||||
|
||||
@@ -24,6 +24,7 @@ class DeleteAction(Enum):
|
||||
CASCADE_MANY = "cascade_many"
|
||||
SET_NULL = "set_null"
|
||||
SET_DEFAULT = "set_default"
|
||||
LEFT_DANGLING = "left_dangling"
|
||||
|
||||
|
||||
class UsedBySerializer(PassiveSerializer):
|
||||
|
||||
@@ -31,6 +31,7 @@ from drf_spectacular.utils import (
|
||||
inline_serializer,
|
||||
)
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import (
|
||||
@@ -42,6 +43,7 @@ from rest_framework.fields import (
|
||||
ListField,
|
||||
SerializerMethodField,
|
||||
)
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import (
|
||||
@@ -52,6 +54,8 @@ from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authentication import TokenAuthentication
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@@ -75,6 +79,7 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserTypes,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import FlowToken
|
||||
@@ -432,6 +437,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = UserSerializer
|
||||
filterset_class = UsersFilter
|
||||
search_fields = ["email", "name", "uuid", "username"]
|
||||
authentication_classes = [
|
||||
TokenAuthentication,
|
||||
SessionAuthentication,
|
||||
AgentAuth,
|
||||
]
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import BoolField, StrField
|
||||
@@ -529,14 +539,13 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
)
|
||||
def service_account(self, request: Request) -> Response:
|
||||
@validate(UserServiceAccountSerializer)
|
||||
def service_account(self, request: Request, body: UserServiceAccountSerializer) -> Response:
|
||||
"""Create a new user account that is marked as a service account"""
|
||||
data = UserServiceAccountSerializer(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
expires = data.validated_data.get("expires", now() + timedelta(days=360))
|
||||
expires = body.validated_data.get("expires", now() + timedelta(days=360))
|
||||
|
||||
username = data.validated_data["name"]
|
||||
expiring = data.validated_data["expiring"]
|
||||
username = body.validated_data["name"]
|
||||
expiring = body.validated_data["expiring"]
|
||||
with atomic():
|
||||
try:
|
||||
user: User = User.objects.create(
|
||||
@@ -554,7 +563,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"user_uid": user.uid,
|
||||
"user_pk": user.pk,
|
||||
}
|
||||
if data.validated_data["create_group"] and self.request.user.has_perm(
|
||||
if body.validated_data["create_group"] and self.request.user.has_perm(
|
||||
"authentik_core.add_group"
|
||||
):
|
||||
group = Group.objects.create(name=username)
|
||||
@@ -624,14 +633,17 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["POST"], permission_classes=[])
|
||||
def set_password(self, request: Request, pk: int) -> Response:
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["POST"],
|
||||
permission_classes=[IsAuthenticated],
|
||||
)
|
||||
@validate(UserPasswordSetSerializer)
|
||||
def set_password(self, request: Request, pk: int, body: UserPasswordSetSerializer) -> Response:
|
||||
"""Set password for user"""
|
||||
data = UserPasswordSetSerializer(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
user: User = self.get_object()
|
||||
try:
|
||||
user.set_password(data.validated_data["password"], request=request)
|
||||
user.set_password(body.validated_data["password"], request=request)
|
||||
user.save()
|
||||
except (ValidationError, IntegrityError) as exc:
|
||||
LOGGER.debug("Failed to set password", exc=exc)
|
||||
@@ -711,7 +723,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
204: OpenApiResponse(description="Successfully started impersonation"),
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["POST"], permission_classes=[])
|
||||
@action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated])
|
||||
def impersonate(self, request: Request, pk: int) -> Response:
|
||||
"""Impersonate a user"""
|
||||
if not request.tenant.impersonation:
|
||||
|
||||
21
authentik/core/migrations/0052_source_promoted.py
Normal file
21
authentik/core/migrations/0052_source_promoted.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-23 14:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0051_group_authentik_c_is_supe_1e5a97_idx"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="source",
|
||||
name="promoted",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="When enabled, this source will be displayed as a prominent button on the login page, instead of a small icon.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-25 16:36
|
||||
|
||||
import django.core.validators
|
||||
import re
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0052_source_promoted"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="application",
|
||||
name="slug",
|
||||
field=models.TextField(
|
||||
help_text="Internal application name, used in URLs.",
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
re.compile("^[-a-zA-Z0-9_]+\\Z"),
|
||||
"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens.",
|
||||
"invalid",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="source",
|
||||
name="slug",
|
||||
field=models.TextField(
|
||||
help_text="Internal source name, used in URLs.",
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
re.compile("^[-a-zA-Z0-9_]+\\Z"),
|
||||
"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens.",
|
||||
"invalid",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 16:22
|
||||
|
||||
import authentik.admin.files.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def clear_cache(apps, schema_editor):
|
||||
CacheEntry = apps.get_model("django_postgres_cache", "CacheEntry")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
CacheEntry.objects.using(db_alias).all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0053_alter_application_slug_alter_source_slug"),
|
||||
("django_postgres_cache", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="application",
|
||||
name="meta_icon",
|
||||
field=authentik.admin.files.fields.FileField(blank=True, default=""),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="source",
|
||||
name="icon",
|
||||
field=authentik.admin.files.fields.FileField(blank=True, default=""),
|
||||
),
|
||||
migrations.RunPython(code=clear_cache),
|
||||
]
|
||||
@@ -11,6 +11,7 @@ from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||
from django.contrib.sessions.base_session import AbstractBaseSession
|
||||
from django.core.validators import validate_slug
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet, options
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
@@ -25,11 +26,13 @@ from model_utils.managers import InheritanceManager
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.fields import FileField
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.blueprints.models import ManagedModel
|
||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||
from authentik.lib.avatars import get_avatar
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.expression.exceptions import ControlFlowException
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
@@ -534,7 +537,11 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
add custom fields and other properties"""
|
||||
|
||||
name = models.TextField(help_text=_("Application's display Name."))
|
||||
slug = models.SlugField(help_text=_("Internal application name, used in URLs."), unique=True)
|
||||
slug = models.TextField(
|
||||
validators=[validate_slug],
|
||||
help_text=_("Internal application name, used in URLs."),
|
||||
unique=True,
|
||||
)
|
||||
group = models.TextField(blank=True, default="")
|
||||
|
||||
provider = models.OneToOneField(
|
||||
@@ -549,13 +556,7 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
default=False, help_text=_("Open launch URL in a new browser tab or window.")
|
||||
)
|
||||
|
||||
# For template applications, this can be set to /static/authentik/applications/*
|
||||
meta_icon = models.FileField(
|
||||
upload_to="application-icons/",
|
||||
default=None,
|
||||
null=True,
|
||||
max_length=500,
|
||||
)
|
||||
meta_icon = FileField(default="", blank=True)
|
||||
meta_description = models.TextField(default="", blank=True)
|
||||
meta_publisher = models.TextField(default="", blank=True)
|
||||
|
||||
@@ -572,17 +573,11 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
|
||||
@property
|
||||
def get_meta_icon(self) -> str | None:
|
||||
"""Get the URL to the App Icon image. If the name is /static or starts with http
|
||||
it is returned as-is"""
|
||||
"""Get the URL to the App Icon image"""
|
||||
if not self.meta_icon:
|
||||
return None
|
||||
if self.meta_icon.name.startswith("http"):
|
||||
return self.meta_icon.name
|
||||
if self.meta_icon.name.startswith("fa://"):
|
||||
return self.meta_icon.name
|
||||
if self.meta_icon.name.startswith("/"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.meta_icon.name
|
||||
return self.meta_icon.url
|
||||
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon)
|
||||
|
||||
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||
@@ -720,23 +715,30 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
MANAGED_INBUILT = "goauthentik.io/sources/inbuilt"
|
||||
|
||||
name = models.TextField(help_text=_("Source's display Name."))
|
||||
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
|
||||
slug = models.TextField(
|
||||
validators=[validate_slug],
|
||||
help_text=_("Internal source name, used in URLs."),
|
||||
unique=True,
|
||||
)
|
||||
|
||||
user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s")
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
promoted = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"When enabled, this source will be displayed as a prominent button on the "
|
||||
"login page, instead of a small icon."
|
||||
),
|
||||
)
|
||||
user_property_mappings = models.ManyToManyField(
|
||||
"PropertyMapping", default=None, blank=True, related_name="source_userpropertymappings_set"
|
||||
)
|
||||
group_property_mappings = models.ManyToManyField(
|
||||
"PropertyMapping", default=None, blank=True, related_name="source_grouppropertymappings_set"
|
||||
)
|
||||
icon = models.FileField(
|
||||
upload_to="source-icons/",
|
||||
default=None,
|
||||
null=True,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
icon = FileField(blank=True, default="")
|
||||
|
||||
authentication_flow = models.ForeignKey(
|
||||
"authentik_flows.Flow",
|
||||
@@ -777,17 +779,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
|
||||
@property
|
||||
def icon_url(self) -> str | None:
|
||||
"""Get the URL to the Icon. If the name is /static or
|
||||
starts with http it is returned as-is"""
|
||||
"""Get the URL to the source icon"""
|
||||
if not self.icon:
|
||||
return None
|
||||
if self.icon.name.startswith("http"):
|
||||
return self.icon.name
|
||||
if self.icon.name.startswith("fa://"):
|
||||
return self.icon.name
|
||||
if self.icon.name.startswith("/"):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.icon.name
|
||||
return self.icon.url
|
||||
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.icon)
|
||||
|
||||
def get_user_path(self) -> str:
|
||||
"""Get user path, fallback to default for formatting errors"""
|
||||
@@ -929,7 +925,7 @@ class ExpiringModel(models.Model):
|
||||
return self.delete(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def filter_not_expired(cls, **kwargs) -> QuerySet["Token"]:
|
||||
def filter_not_expired(cls, **kwargs) -> QuerySet["Self"]:
|
||||
"""Filer for tokens which are not expired yet or are not expiring,
|
||||
and match filters in `kwargs`"""
|
||||
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""authentik core signals"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
@@ -17,6 +19,8 @@ from authentik.core.models import (
|
||||
User,
|
||||
default_token_duration,
|
||||
)
|
||||
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
|
||||
from authentik.root.ws.consumer import build_device_group
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
@@ -47,6 +51,16 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
|
||||
if session:
|
||||
session.save()
|
||||
|
||||
if not RefreshOtherFlowsAfterAuthentication().get():
|
||||
return
|
||||
layer = get_channel_layer()
|
||||
device_cookie = request.COOKIES.get("authentik_device")
|
||||
if device_cookie:
|
||||
async_to_sync(layer.group_send)(
|
||||
build_device_group(device_cookie),
|
||||
{"type": "event.session.authenticated"},
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=AuthenticatedSession)
|
||||
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load authentik_core %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="{{ LANGUAGE_CODE }}"
|
||||
data-theme="{% if ui_theme == "dark" %}dark{% else %}light{% endif %}"
|
||||
data-theme-choice="{% if ui_theme == "dark" %}dark{% elif ui_theme == "light" %}light{% else %}auto{% endif %}"
|
||||
>
|
||||
@@ -15,6 +17,7 @@
|
||||
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
|
||||
<link rel="icon" href="{{ brand.branding_favicon_url }}">
|
||||
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
|
||||
|
||||
{% block head_before %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from json import loads
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -57,91 +55,6 @@ class TestApplicationsAPI(APITestCase):
|
||||
f"https://{self.user.username}-test.test.goauthentik.io/{self.user.username}",
|
||||
)
|
||||
|
||||
def test_set_icon(self):
|
||||
"""Test set_icon"""
|
||||
file = ContentFile(b"text", "name")
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data=encode_multipart(data={"file": file}, boundary=BOUNDARY),
|
||||
content_type=MULTIPART_CONTENT,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
app_raw = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:application-detail",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
)
|
||||
app = loads(app_raw.content)
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, app["meta_icon"])
|
||||
self.assertEqual(self.allowed.meta_icon.read(), b"text")
|
||||
|
||||
def test_set_icon_relative(self):
|
||||
"""Test set_icon (relative path)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon-url",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data={"url": "relative/path"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, "/media/public/relative/path")
|
||||
|
||||
def test_set_icon_absolute(self):
|
||||
"""Test set_icon (absolute path)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon-url",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data={"url": "/relative/path"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, "/relative/path")
|
||||
|
||||
def test_set_icon_url(self):
|
||||
"""Test set_icon (url)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon-url",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data={"url": "https://authentik.company/img.png"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, "https://authentik.company/img.png")
|
||||
|
||||
def test_set_icon_fa(self):
|
||||
"""Test set_icon (url)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:application-set-icon-url",
|
||||
kwargs={"slug": self.allowed.slug},
|
||||
),
|
||||
data={"url": "fa://fa-check-circle"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.allowed.refresh_from_db()
|
||||
self.assertEqual(self.allowed.get_meta_icon, "fa://fa-check-circle")
|
||||
|
||||
def test_check_access(self):
|
||||
"""Test check_access operation"""
|
||||
self.client.force_login(self.user)
|
||||
@@ -210,7 +123,8 @@ class TestApplicationsAPI(APITestCase):
|
||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||
"open_in_new_tab": True,
|
||||
"meta_icon": None,
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
"policy_engine_mode": "any",
|
||||
@@ -264,7 +178,8 @@ class TestApplicationsAPI(APITestCase):
|
||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||
"open_in_new_tab": True,
|
||||
"meta_icon": None,
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
"policy_engine_mode": "any",
|
||||
@@ -272,7 +187,8 @@ class TestApplicationsAPI(APITestCase):
|
||||
{
|
||||
"launch_url": None,
|
||||
"meta_description": "",
|
||||
"meta_icon": None,
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_launch_url": "",
|
||||
"open_in_new_tab": False,
|
||||
"meta_publisher": "",
|
||||
|
||||
@@ -8,6 +8,8 @@ from authentik.brands.models import Brand
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
|
||||
|
||||
class TestApplicationsViews(FlowTestCase):
|
||||
@@ -15,7 +17,7 @@ class TestApplicationsViews(FlowTestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_admin_user()
|
||||
self.allowed = Application.objects.create(
|
||||
self.app = Application.objects.create(
|
||||
name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s"
|
||||
)
|
||||
|
||||
@@ -28,7 +30,7 @@ class TestApplicationsViews(FlowTestCase):
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_core:application-launch",
|
||||
kwargs={"application_slug": self.allowed.slug},
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
follow=True,
|
||||
)
|
||||
@@ -52,8 +54,63 @@ class TestApplicationsViews(FlowTestCase):
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_core:application-launch",
|
||||
kwargs={"application_slug": self.allowed.slug},
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, f"https://goauthentik.io/{self.user.username}")
|
||||
|
||||
def test_redirect_application_auth_flow(self):
|
||||
"""Test launching an application with a provider and an authentication flow set"""
|
||||
self.client.logout()
|
||||
auth_flow = create_test_flow()
|
||||
prov = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authentication_flow=auth_flow,
|
||||
)
|
||||
self.app.provider = prov
|
||||
self.app.save()
|
||||
with self.assertFlowFinishes() as plan:
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_core:application-launch",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url,
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": auth_flow.slug}),
|
||||
)
|
||||
plan = plan()
|
||||
self.assertEqual(len(plan.bindings), 1)
|
||||
self.assertTrue(plan.bindings[0].stage.is_in_memory)
|
||||
|
||||
def test_redirect_application_no_auth(self):
|
||||
"""Test launching an application with a provider and an authentication flow set"""
|
||||
self.client.logout()
|
||||
empty_flow = create_test_flow()
|
||||
brand: Brand = create_test_brand()
|
||||
brand.flow_authentication = empty_flow
|
||||
brand.save()
|
||||
|
||||
prov = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
)
|
||||
self.app.provider = prov
|
||||
self.app.save()
|
||||
with self.assertFlowFinishes() as plan:
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_core:application-launch",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url,
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": empty_flow.slug}),
|
||||
)
|
||||
plan = plan()
|
||||
self.assertEqual(len(plan.bindings), 1)
|
||||
self.assertTrue(plan.bindings[0].stage.is_in_memory)
|
||||
|
||||
@@ -8,11 +8,10 @@ from guardian.utils import get_anonymous_user
|
||||
from authentik.core.models import SourceUserMatchingModes, User
|
||||
from authentik.core.sources.flow_manager import Action
|
||||
from authentik.core.sources.stage import PostSourceStage
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.core.tests.utils import RequestFactory, create_test_flow
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import get_request
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
from authentik.policies.expression.models import ExpressionPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
@@ -34,10 +33,11 @@ class TestSourceFlowManager(TestCase):
|
||||
enrollment_flow=self.enrollment_flow,
|
||||
)
|
||||
self.identifier = generate_id()
|
||||
self.request_factory = RequestFactory()
|
||||
|
||||
def test_unauthenticated_enroll(self):
|
||||
"""Test un-authenticated user enrolling"""
|
||||
request = get_request("/", user=AnonymousUser())
|
||||
request = self.request_factory.get("/", user=AnonymousUser())
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, request, self.identifier, {"info": {}}, {}
|
||||
)
|
||||
@@ -53,7 +53,7 @@ class TestSourceFlowManager(TestCase):
|
||||
UserOAuthSourceConnection.objects.create(
|
||||
user=get_anonymous_user(), source=self.source, identifier=self.identifier
|
||||
)
|
||||
request = get_request("/", user=AnonymousUser())
|
||||
request = self.request_factory.get("/", user=AnonymousUser())
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, request, self.identifier, {"info": {}}, {}
|
||||
)
|
||||
@@ -67,7 +67,7 @@ class TestSourceFlowManager(TestCase):
|
||||
def test_authenticated_link(self):
|
||||
"""Test authenticated user linking"""
|
||||
user = User.objects.create(username="foo", email="foo@bar.baz")
|
||||
request = get_request("/", user=user)
|
||||
request = self.request_factory.get("/", user=user)
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, request, self.identifier, {"info": {}}, {}
|
||||
)
|
||||
@@ -87,7 +87,7 @@ class TestSourceFlowManager(TestCase):
|
||||
UserOAuthSourceConnection.objects.create(
|
||||
user=user, source=self.source, identifier=self.identifier
|
||||
)
|
||||
request = get_request("/", user=user)
|
||||
request = self.request_factory.get("/", user=user)
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, request, self.identifier, {"info": {}}, {}
|
||||
)
|
||||
@@ -100,7 +100,11 @@ class TestSourceFlowManager(TestCase):
|
||||
def test_unauthenticated_link(self):
|
||||
"""Test un-authenticated user linking"""
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, get_request("/"), self.identifier, {"info": {}}, {}
|
||||
self.source,
|
||||
self.request_factory.get("/", user=get_anonymous_user()),
|
||||
self.identifier,
|
||||
{"info": {}},
|
||||
{},
|
||||
)
|
||||
action, connection = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.LINK)
|
||||
@@ -114,7 +118,11 @@ class TestSourceFlowManager(TestCase):
|
||||
|
||||
# Without email, deny
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, get_request("/", user=AnonymousUser()), self.identifier, {"info": {}}, {}
|
||||
self.source,
|
||||
self.request_factory.get("/", user=AnonymousUser()),
|
||||
self.identifier,
|
||||
{"info": {}},
|
||||
{},
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.DENY)
|
||||
@@ -122,7 +130,7 @@ class TestSourceFlowManager(TestCase):
|
||||
# With email
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
get_request("/", user=AnonymousUser()),
|
||||
self.request_factory.get("/", user=AnonymousUser()),
|
||||
self.identifier,
|
||||
{
|
||||
"info": {
|
||||
@@ -142,7 +150,11 @@ class TestSourceFlowManager(TestCase):
|
||||
|
||||
# Without username, deny
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, get_request("/", user=AnonymousUser()), self.identifier, {"info": {}}, {}
|
||||
self.source,
|
||||
self.request_factory.get("/", user=AnonymousUser()),
|
||||
self.identifier,
|
||||
{"info": {}},
|
||||
{},
|
||||
)
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.DENY)
|
||||
@@ -150,7 +162,7 @@ class TestSourceFlowManager(TestCase):
|
||||
# With username
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
get_request("/", user=AnonymousUser()),
|
||||
self.request_factory.get("/", user=AnonymousUser()),
|
||||
self.identifier,
|
||||
{
|
||||
"info": {"username": "foo"},
|
||||
@@ -169,7 +181,7 @@ class TestSourceFlowManager(TestCase):
|
||||
# With non-existent username, enroll
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
get_request("/", user=AnonymousUser()),
|
||||
self.request_factory.get("/", user=AnonymousUser()),
|
||||
self.identifier,
|
||||
{
|
||||
"info": {
|
||||
@@ -184,7 +196,7 @@ class TestSourceFlowManager(TestCase):
|
||||
# With username
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
get_request("/", user=AnonymousUser()),
|
||||
self.request_factory.get("/", user=AnonymousUser()),
|
||||
self.identifier,
|
||||
{
|
||||
"info": {"username": "foo"},
|
||||
@@ -201,7 +213,7 @@ class TestSourceFlowManager(TestCase):
|
||||
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
get_request("/", user=AnonymousUser()),
|
||||
self.request_factory.get("/", user=AnonymousUser()),
|
||||
self.identifier,
|
||||
{
|
||||
"info": {"username": "foo"},
|
||||
@@ -230,7 +242,7 @@ class TestSourceFlowManager(TestCase):
|
||||
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
get_request("/", user=AnonymousUser()),
|
||||
self.request_factory.get("/", user=AnonymousUser()),
|
||||
self.identifier,
|
||||
{
|
||||
"info": {"username": "foo"},
|
||||
|
||||
@@ -4,9 +4,9 @@ from django.test import TestCase
|
||||
|
||||
from authentik.core.auth import TokenBackend
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
from authentik.core.tests.utils import RequestFactory
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.tests.utils import get_request
|
||||
|
||||
|
||||
class TestTokenAuth(TestCase):
|
||||
@@ -17,8 +17,9 @@ class TestTokenAuth(TestCase):
|
||||
self.token = Token.objects.create(
|
||||
expiring=False, user=self.user, intent=TokenIntents.INTENT_APP_PASSWORD
|
||||
)
|
||||
self.request_factory = RequestFactory()
|
||||
# To test with session we need to create a request and pass it through all middlewares
|
||||
self.request = get_request("/")
|
||||
self.request = self.request_factory.get("/")
|
||||
self.request.session[SESSION_KEY_PLAN] = FlowPlan("test")
|
||||
|
||||
def test_token_auth(self):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from json import loads
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.urls.base import reverse
|
||||
from requests_mock import Mocker
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -46,6 +47,7 @@ class TestUsersAvatars(APITestCase):
|
||||
"6cf71fb567ae36025a9d4ea86b?size=158&rating=g&default=404"
|
||||
),
|
||||
text="foo",
|
||||
headers={"Content-Type": "image/png"},
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -89,3 +91,170 @@ class TestUsersAvatars(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
||||
|
||||
def test_avatars_custom_content_type_valid(self):
|
||||
"""Test custom avatar URL with valid image Content-Type"""
|
||||
cache.clear()
|
||||
self.set_avatar_mode("https://example.com/avatar/%(username)s")
|
||||
self.client.force_login(self.admin)
|
||||
with Mocker() as mocker:
|
||||
mocker.head(
|
||||
f"https://example.com/avatar/{self.admin.username}",
|
||||
headers={"Content-Type": "image/png"},
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(
|
||||
body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}"
|
||||
)
|
||||
|
||||
def test_avatars_custom_content_type_invalid(self):
|
||||
"""Test custom avatar URL with invalid Content-Type falls back"""
|
||||
cache.clear()
|
||||
self.set_avatar_mode("https://example.com/avatar/%(username)s,initials")
|
||||
self.client.force_login(self.admin)
|
||||
with Mocker() as mocker:
|
||||
mocker.head(
|
||||
f"https://example.com/avatar/{self.admin.username}",
|
||||
headers={"Content-Type": "text/html"},
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
# Should fallback to initials since Content-Type is not image/*
|
||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
||||
|
||||
def test_avatars_custom_content_type_missing(self):
|
||||
"""Test custom avatar URL with missing Content-Type header falls back"""
|
||||
cache.clear()
|
||||
self.set_avatar_mode("https://example.com/avatar/%(username)s,initials")
|
||||
self.client.force_login(self.admin)
|
||||
with Mocker() as mocker:
|
||||
mocker.head(
|
||||
f"https://example.com/avatar/{self.admin.username}",
|
||||
headers={},
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
# Should fallback to initials since Content-Type header is missing
|
||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
||||
|
||||
def test_avatars_custom_404_cached(self):
|
||||
"""Test that 404 responses are cached with TTL"""
|
||||
cache.clear()
|
||||
self.set_avatar_mode("https://example.com/avatar/%(username)s")
|
||||
self.client.force_login(self.admin)
|
||||
with Mocker() as mocker:
|
||||
mocker.head(
|
||||
f"https://example.com/avatar/{self.admin.username}",
|
||||
status_code=404,
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
# Should fallback to default avatar
|
||||
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
|
||||
|
||||
# Verify cache was set with the expected structure
|
||||
from hashlib import md5
|
||||
|
||||
mail_hash = md5(self.admin.email.lower().encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
cache_key = f"goauthentik.io/lib/avatars/example.com/{mail_hash}"
|
||||
self.assertIsNone(cache.get(cache_key))
|
||||
# Verify TTL was set (cache entry exists)
|
||||
self.assertTrue(cache.has_key(cache_key))
|
||||
|
||||
def test_avatars_custom_redirect(self):
|
||||
"""Test custom avatar URL follows redirects"""
|
||||
cache.clear()
|
||||
self.set_avatar_mode("https://example.com/avatar/%(username)s")
|
||||
self.client.force_login(self.admin)
|
||||
with Mocker() as mocker:
|
||||
# Mock a redirect
|
||||
mocker.head(
|
||||
f"https://example.com/avatar/{self.admin.username}",
|
||||
status_code=302,
|
||||
headers={"Location": "https://cdn.example.com/final-avatar.png"},
|
||||
)
|
||||
mocker.head(
|
||||
"https://cdn.example.com/final-avatar.png",
|
||||
headers={"Content-Type": "image/png"},
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
# Should return the original URL (not the redirect destination)
|
||||
self.assertEqual(
|
||||
body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}"
|
||||
)
|
||||
|
||||
def test_avatars_hostname_availability_cache(self):
|
||||
"""Test that hostname availability is cached when domain fails"""
|
||||
from requests.exceptions import Timeout
|
||||
|
||||
cache.clear()
|
||||
self.set_avatar_mode("https://failing.example.com/avatar/%(username)s,initials")
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
with Mocker() as mocker:
|
||||
# First request times out
|
||||
mocker.head(
|
||||
f"https://failing.example.com/avatar/{self.admin.username}",
|
||||
exc=Timeout("Connection timeout"),
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
# Should fallback to initials
|
||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
||||
|
||||
# Verify hostname is marked as unavailable
|
||||
cache_key_hostname = "goauthentik.io/lib/avatars/failing.example.com/available"
|
||||
self.assertFalse(cache.get(cache_key_hostname, True))
|
||||
|
||||
# Second request should not even try to fetch (hostname cached as unavailable)
|
||||
with Mocker() as mocker:
|
||||
# This should NOT be called due to hostname cache
|
||||
mocker.head(
|
||||
f"https://failing.example.com/avatar/{self.admin.username}",
|
||||
headers={"Content-Type": "image/png"},
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
# Should still fallback to initials without making a request
|
||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
||||
# Verify no request was made (request_history should be empty)
|
||||
self.assertEqual(len(mocker.request_history), 0)
|
||||
|
||||
def test_avatars_gravatar_uses_url_validation(self):
|
||||
"""Test that Gravatar now uses avatar_mode_url validation (regression test)"""
|
||||
cache.clear()
|
||||
self.set_avatar_mode("gravatar")
|
||||
self.admin.email = "test@example.com"
|
||||
self.admin.save()
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
with Mocker() as mocker:
|
||||
# Mock Gravatar to return non-image content
|
||||
from hashlib import sha256
|
||||
|
||||
mail_hash = sha256(self.admin.email.lower().encode("utf-8")).hexdigest()
|
||||
gravatar_url = (
|
||||
f"https://www.gravatar.com/avatar/{mail_hash}?size=158&rating=g&default=404"
|
||||
)
|
||||
|
||||
mocker.head(
|
||||
gravatar_url,
|
||||
headers={"Content-Type": "text/html"},
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
# Should fallback to default avatar since Content-Type is not image/*
|
||||
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"""Test Utils"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.messages.middleware import MessageMiddleware
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory as BaseRequestFactory
|
||||
from django.utils.text import slugify
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
@@ -60,3 +67,45 @@ def create_test_cert(alg=PrivateKeyAlg.RSA) -> CertificateKeyPair:
|
||||
)
|
||||
builder.common_name = generate_id()
|
||||
return builder.save()
|
||||
|
||||
|
||||
def dummy_get_response(request: HttpRequest): # pragma: no cover
|
||||
"""Dummy get_response for SessionMiddleware"""
|
||||
return None
|
||||
|
||||
|
||||
class RequestFactory(BaseRequestFactory):
|
||||
|
||||
def generic(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
data: Any = "",
|
||||
content_type="application/octet-stream",
|
||||
secure=False,
|
||||
*,
|
||||
headers=None,
|
||||
query_params=None,
|
||||
**extra,
|
||||
):
|
||||
user = extra.pop("user", None)
|
||||
request = super().generic(
|
||||
method,
|
||||
path,
|
||||
data,
|
||||
content_type,
|
||||
secure,
|
||||
headers=headers,
|
||||
query_params=query_params,
|
||||
**extra,
|
||||
)
|
||||
request.user = user if user else AnonymousUser()
|
||||
|
||||
middleware = SessionMiddleware(dummy_get_response)
|
||||
middleware.process_request(request)
|
||||
request.session.save()
|
||||
middleware = MessageMiddleware(dummy_get_response)
|
||||
middleware.process_request(request)
|
||||
request.session.save()
|
||||
|
||||
return request
|
||||
|
||||
@@ -21,6 +21,9 @@ class UILoginButton:
|
||||
# Icon URL, used as-is
|
||||
icon_url: str | None = None
|
||||
|
||||
# Whether this source should be displayed as a prominent button
|
||||
promoted: bool = False
|
||||
|
||||
|
||||
class UserSettingSerializer(PassiveSerializer):
|
||||
"""Serializer for User settings for stages and sources"""
|
||||
|
||||
@@ -28,8 +28,8 @@ from authentik.core.views.interface import (
|
||||
)
|
||||
from authentik.flows.views.interface import FlowInterfaceView
|
||||
from authentik.root.asgi_middleware import AuthMiddlewareStack
|
||||
from authentik.root.messages.consumer import MessageConsumer
|
||||
from authentik.root.middleware import ChannelsLoggingMiddleware
|
||||
from authentik.root.ws.consumer import MessageConsumer
|
||||
from authentik.tenants.channels import TenantsAwareMiddleware
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -16,7 +16,6 @@ from authentik.flows.models import FlowDesignation, in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import (
|
||||
SESSION_KEY_APPLICATION_PRE,
|
||||
ToDefaultFlow,
|
||||
)
|
||||
from authentik.stages.consent.stage import (
|
||||
@@ -37,10 +36,14 @@ class RedirectToAppLaunch(View):
|
||||
# Check if we're authenticated already, saves us the flow run
|
||||
if request.user.is_authenticated:
|
||||
return HttpResponseRedirect(app.get_launch_url(request.user))
|
||||
self.request.session[SESSION_KEY_APPLICATION_PRE] = app
|
||||
# otherwise, do a custom flow plan that includes the application that's
|
||||
# being accessed, to improve usability
|
||||
flow = ToDefaultFlow(request=request, designation=FlowDesignation.AUTHENTICATION).get_flow()
|
||||
if app and app.provider and app.provider.authentication_flow:
|
||||
flow = app.provider.authentication_flow
|
||||
else:
|
||||
flow = ToDefaultFlow.get_flow(
|
||||
request=request, designation=FlowDesignation.AUTHENTICATION
|
||||
)
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
@@ -55,6 +58,8 @@ class RedirectToAppLaunch(View):
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
raise Http404 from None
|
||||
# We redirect with an in_memory stage instead of `?next=...` as the launch URL
|
||||
# might be formatted with the user, which hasn't logged in yet
|
||||
plan.append_stage(in_memory_stage(RedirectToAppStage))
|
||||
return plan.to_redirect(request, flow)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from django.http.response import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic.base import RedirectView, TemplateView
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentik import authentik_build_hash
|
||||
from authentik.admin.tasks import LOCAL_VERSION
|
||||
@@ -47,7 +46,7 @@ class InterfaceView(TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
brand = CurrentBrandSerializer(self.request.brand)
|
||||
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
|
||||
kwargs["config_json"] = dumps(ConfigView.get_config(self.request).data)
|
||||
kwargs["ui_theme"] = brand.data["ui_theme"]
|
||||
kwargs["brand_json"] = dumps(brand.data)
|
||||
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
|
||||
|
||||
@@ -27,12 +27,14 @@ from rest_framework.fields import (
|
||||
SerializerMethodField,
|
||||
)
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import UserTypes
|
||||
@@ -41,7 +43,7 @@ from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
|
||||
from authentik.crypto.models import CertificateKeyPair, KeyType
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.filters import ObjectFilter, SecretKeyFilter
|
||||
from authentik.rbac.filters import SecretKeyFilter
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -276,22 +278,22 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
},
|
||||
)
|
||||
@action(detail=False, methods=["POST"])
|
||||
def generate(self, request: Request) -> Response:
|
||||
@validate(CertificateGenerationSerializer)
|
||||
def generate(self, request: Request, body: CertificateGenerationSerializer) -> Response:
|
||||
"""Generate a new, self-signed certificate-key pair"""
|
||||
data = CertificateGenerationSerializer(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
raw_san = data.validated_data.get("subject_alt_name", "")
|
||||
raw_san = body.validated_data.get("subject_alt_name", "")
|
||||
sans = raw_san.split(",") if raw_san != "" else []
|
||||
builder = CertificateBuilder(data.validated_data["name"])
|
||||
builder.alg = data.validated_data["alg"]
|
||||
builder = CertificateBuilder(body.validated_data["name"])
|
||||
builder.alg = body.validated_data["alg"]
|
||||
builder.build(
|
||||
subject_alt_names=sans,
|
||||
validity_days=int(data.validated_data["validity_days"]),
|
||||
validity_days=int(body.validated_data["validity_days"]),
|
||||
)
|
||||
instance = builder.save()
|
||||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
@permission_required("view_certificatekeypair_certificate")
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
@@ -302,7 +304,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
],
|
||||
responses={200: CertificateDataSerializer(many=False)},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
|
||||
@action(detail=True, pagination_class=None, permission_classes=[IsAuthenticated])
|
||||
def view_certificate(self, request: Request, pk: str) -> Response:
|
||||
"""Return certificate-key pairs certificate and log access"""
|
||||
certificate: CertificateKeyPair = self.get_object()
|
||||
@@ -323,6 +325,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
return response
|
||||
return Response(CertificateDataSerializer({"data": certificate.certificate_data}).data)
|
||||
|
||||
@permission_required("view_certificatekeypair_key")
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
@@ -333,7 +336,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
],
|
||||
responses={200: CertificateDataSerializer(many=False)},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
|
||||
@action(detail=True, pagination_class=None, permission_classes=[IsAuthenticated])
|
||||
def view_private_key(self, request: Request, pk: str) -> Response:
|
||||
"""Return certificate-key pairs private key and log access"""
|
||||
certificate: CertificateKeyPair = self.get_object()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""authentik crypto app config"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from dramatiq.broker import get_broker
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
@@ -47,10 +45,7 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
||||
cert: CertificateKeyPair | None = CertificateKeyPair.objects.filter(
|
||||
managed=MANAGED_KEY
|
||||
).first()
|
||||
now = datetime.now(tz=UTC)
|
||||
if not cert or (
|
||||
now < cert.certificate.not_valid_after_utc or now > cert.certificate.not_valid_after_utc
|
||||
):
|
||||
if not cert:
|
||||
self._create_update_cert()
|
||||
|
||||
@ManagedAppConfig.reconcile_tenant
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-20 14:50
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0004_alter_certificatekeypair_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="certificatekeypair",
|
||||
options={
|
||||
"permissions": [
|
||||
(
|
||||
"view_certificatekeypair_certificate",
|
||||
"View Certificate-Key pair's certificate",
|
||||
),
|
||||
("view_certificatekeypair_key", "View Certificate-Key pair's private key"),
|
||||
],
|
||||
"verbose_name": "Certificate-Key Pair",
|
||||
"verbose_name_plural": "Certificate-Key Pairs",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from binascii import hexlify
|
||||
from hashlib import md5
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
from textwrap import wrap
|
||||
from uuid import uuid4
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
@@ -25,6 +27,11 @@ from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def format_cert(raw_pam: str) -> str:
|
||||
"""Format a PEM certificate that is either missing its header/footer or is in a single line"""
|
||||
return "\n".join([PEM_HEADER, *wrap(raw_pam.replace("\n", ""), 64), PEM_FOOTER])
|
||||
|
||||
|
||||
class KeyType(models.TextChoices):
|
||||
"""Cryptographic key algorithm types"""
|
||||
|
||||
@@ -140,3 +147,7 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
class Meta:
|
||||
verbose_name = _("Certificate-Key Pair")
|
||||
verbose_name_plural = _("Certificate-Key Pairs")
|
||||
permissions = [
|
||||
("view_certificatekeypair_certificate", _("View Certificate-Key pair's certificate")),
|
||||
("view_certificatekeypair_key", _("View Certificate-Key pair's private key")),
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user