Compare commits
3 Commits
version/20
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
4d791f4fef | |||
c03a069f02 | |||
040adb8ce7 |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2023.2.2
|
current_version = 2022.12.3
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||||
|
@ -38,14 +38,6 @@ runs:
|
|||||||
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
|
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
|
||||||
```
|
```
|
||||||
|
|
||||||
For arm64, use these values:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
|
|
||||||
AUTHENTIK_TAG=${{ inputs.tag }}-arm64
|
|
||||||
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
|
|
||||||
```
|
|
||||||
|
|
||||||
Afterwards, run the upgrade commands from the latest release notes.
|
Afterwards, run the upgrade commands from the latest release notes.
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
@ -62,17 +54,6 @@ runs:
|
|||||||
tag: ${{ inputs.tag }}
|
tag: ${{ inputs.tag }}
|
||||||
```
|
```
|
||||||
|
|
||||||
For arm64, use these values:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
authentik:
|
|
||||||
outposts:
|
|
||||||
container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
|
|
||||||
image:
|
|
||||||
repository: ghcr.io/goauthentik/dev-server
|
|
||||||
tag: ${{ inputs.tag }}-arm64
|
|
||||||
```
|
|
||||||
|
|
||||||
Afterwards, run the upgrade commands from the latest release notes.
|
Afterwards, run the upgrade commands from the latest release notes.
|
||||||
</details>
|
</details>
|
||||||
edit-mode: replace
|
edit-mode: replace
|
||||||
|
@ -17,9 +17,6 @@ outputs:
|
|||||||
sha:
|
sha:
|
||||||
description: "sha"
|
description: "sha"
|
||||||
value: ${{ steps.ev.outputs.sha }}
|
value: ${{ steps.ev.outputs.sha }}
|
||||||
shortHash:
|
|
||||||
description: "shortHash"
|
|
||||||
value: ${{ steps.ev.outputs.shortHash }}
|
|
||||||
version:
|
version:
|
||||||
description: "version"
|
description: "version"
|
||||||
value: ${{ steps.ev.outputs.version }}
|
value: ${{ steps.ev.outputs.version }}
|
||||||
@ -56,7 +53,6 @@ runs:
|
|||||||
print("branchNameContainer=%s" % safe_branch_name, file=_output)
|
print("branchNameContainer=%s" % safe_branch_name, file=_output)
|
||||||
print("timestamp=%s" % int(time()), file=_output)
|
print("timestamp=%s" % int(time()), file=_output)
|
||||||
print("sha=%s" % os.environ["GITHUB_SHA"], file=_output)
|
print("sha=%s" % os.environ["GITHUB_SHA"], file=_output)
|
||||||
print("shortHash=%s" % os.environ["GITHUB_SHA"][:7], file=_output)
|
|
||||||
print("shouldBuild=%s" % should_build, file=_output)
|
print("shouldBuild=%s" % should_build, file=_output)
|
||||||
print("version=%s" % version, file=_output)
|
print("version=%s" % version, file=_output)
|
||||||
print("versionFamily=%s" % version_family, file=_output)
|
print("versionFamily=%s" % version_family, file=_output)
|
||||||
|
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@ -18,7 +18,7 @@ runs:
|
|||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v3.1.0
|
uses: actions/setup-node@v3.1.0
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Setup dependencies
|
- name: Setup dependencies
|
||||||
|
108
.github/workflows/ci-main.yml
vendored
108
.github/workflows/ci-main.yml
vendored
@ -80,7 +80,6 @@ jobs:
|
|||||||
run: poetry run python -m lifecycle.migrate
|
run: poetry run python -m lifecycle.migrate
|
||||||
test-unittest:
|
test-unittest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
@ -95,7 +94,6 @@ jobs:
|
|||||||
flags: unit
|
flags: unit
|
||||||
test-integration:
|
test-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
@ -104,32 +102,14 @@ jobs:
|
|||||||
uses: helm/kind-action@v1.5.0
|
uses: helm/kind-action@v1.5.0
|
||||||
- name: run integration
|
- name: run integration
|
||||||
run: |
|
run: |
|
||||||
poetry run coverage run manage.py test tests/integration
|
poetry run make test-integration
|
||||||
poetry run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
flags: integration
|
flags: integration
|
||||||
test-e2e:
|
test-e2e-provider:
|
||||||
name: test-e2e (${{ matrix.job.name }})
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
job:
|
|
||||||
- name: proxy
|
|
||||||
glob: tests/e2e/test_provider_proxy*
|
|
||||||
- name: oauth
|
|
||||||
glob: tests/e2e/test_provider_oauth2* tests/e2e/test_source_oauth*
|
|
||||||
- name: oauth-oidc
|
|
||||||
glob: tests/e2e/test_provider_oidc*
|
|
||||||
- name: saml
|
|
||||||
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
|
|
||||||
- name: ldap
|
|
||||||
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
|
|
||||||
- name: flows
|
|
||||||
glob: tests/e2e/test_flows*
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
@ -151,7 +131,36 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
poetry run coverage run manage.py test ${{ matrix.job.glob }}
|
poetry run make test-e2e-provider
|
||||||
|
poetry run coverage xml
|
||||||
|
- if: ${{ always() }}
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
flags: e2e
|
||||||
|
test-e2e-rest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup authentik env
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
- name: Setup e2e env (chrome, etc)
|
||||||
|
run: |
|
||||||
|
docker-compose -f tests/e2e/docker-compose.yml up -d
|
||||||
|
- id: cache-web
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: web/dist
|
||||||
|
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
|
||||||
|
- name: prepare web ui
|
||||||
|
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||||
|
working-directory: web/
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
make -C .. gen-client-ts
|
||||||
|
npm run build
|
||||||
|
- name: run e2e
|
||||||
|
run: |
|
||||||
|
poetry run make test-e2e-rest
|
||||||
poetry run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
@ -164,7 +173,8 @@ jobs:
|
|||||||
- test-migrations-from-stable
|
- test-migrations-from-stable
|
||||||
- test-unittest
|
- test-unittest
|
||||||
- test-integration
|
- test-integration
|
||||||
- test-e2e
|
- test-e2e-rest
|
||||||
|
- test-e2e-provider
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- run: echo mark
|
||||||
@ -172,6 +182,11 @@ jobs:
|
|||||||
needs: ci-core-mark
|
needs: ci-core-mark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
arch:
|
||||||
|
- 'linux/amd64'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
@ -190,8 +205,8 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
secrets: |
|
secrets: |
|
||||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||||
@ -199,49 +214,14 @@ jobs:
|
|||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}
|
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}
|
||||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
|
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
|
platforms: ${{ matrix.arch }}
|
||||||
- name: Comment on PR
|
- name: Comment on PR
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: ./.github/actions/comment-pr-instructions
|
uses: ./.github/actions/comment-pr-instructions
|
||||||
with:
|
with:
|
||||||
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
|
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
|
||||||
build-arm64:
|
|
||||||
needs: ci-core-mark
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 120
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v2.1.0
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
- name: prepare variables
|
|
||||||
uses: ./.github/actions/docker-push-variables
|
|
||||||
id: ev
|
|
||||||
env:
|
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
- name: Login to Container Registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build Docker Image
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
secrets: |
|
|
||||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
|
||||||
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
|
||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
|
||||||
tags: |
|
|
||||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-arm64
|
|
||||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}-arm64
|
|
||||||
build-args: |
|
|
||||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
|
||||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
|
||||||
platforms: linux/arm64
|
|
||||||
|
16
.github/workflows/ci-outpost.yml
vendored
16
.github/workflows/ci-outpost.yml
vendored
@ -28,8 +28,6 @@ jobs:
|
|||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v3
|
||||||
with:
|
|
||||||
args: --timeout 5000s
|
|
||||||
test-unittest:
|
test-unittest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@ -49,7 +47,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- run: echo mark
|
||||||
build-container:
|
build:
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
needs:
|
needs:
|
||||||
- ci-outpost-mark
|
- ci-outpost-mark
|
||||||
@ -82,20 +80,20 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: Build Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}
|
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}
|
||||||
|
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}
|
||||||
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
|
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
|
||||||
file: ${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
platforms: ${{ matrix.arch }}
|
platforms: ${{ matrix.arch }}
|
||||||
context: .
|
build-outpost-binary:
|
||||||
build-binary:
|
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
needs:
|
needs:
|
||||||
- ci-outpost-mark
|
- ci-outpost-mark
|
||||||
@ -113,9 +111,9 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.6.0
|
- uses: actions/setup-node@v3.5.1
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
|
20
.github/workflows/ci-web.yml
vendored
20
.github/workflows/ci-web.yml
vendored
@ -15,9 +15,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.6.0
|
- uses: actions/setup-node@v3.5.1
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- working-directory: web/
|
- working-directory: web/
|
||||||
@ -31,9 +31,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.6.0
|
- uses: actions/setup-node@v3.5.1
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- working-directory: web/
|
- working-directory: web/
|
||||||
@ -47,9 +47,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.6.0
|
- uses: actions/setup-node@v3.5.1
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- working-directory: web/
|
- working-directory: web/
|
||||||
@ -63,9 +63,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.6.0
|
- uses: actions/setup-node@v3.5.1
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- working-directory: web/
|
- working-directory: web/
|
||||||
@ -95,9 +95,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.6.0
|
- uses: actions/setup-node@v3.5.1
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- working-directory: web/
|
- working-directory: web/
|
||||||
|
19
.github/workflows/ci-website.yml
vendored
19
.github/workflows/ci-website.yml
vendored
@ -15,9 +15,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.6.0
|
- uses: actions/setup-node@v3.5.1
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: website/package-lock.json
|
cache-dependency-path: website/package-lock.json
|
||||||
- working-directory: website/
|
- working-directory: website/
|
||||||
@ -25,24 +25,9 @@ jobs:
|
|||||||
- name: prettier
|
- name: prettier
|
||||||
working-directory: website/
|
working-directory: website/
|
||||||
run: npm run prettier-check
|
run: npm run prettier-check
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3.6.0
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: website/package-lock.json
|
|
||||||
- working-directory: website/
|
|
||||||
run: npm ci
|
|
||||||
- name: test
|
|
||||||
working-directory: website/
|
|
||||||
run: npm test
|
|
||||||
ci-website-mark:
|
ci-website-mark:
|
||||||
needs:
|
needs:
|
||||||
- lint-prettier
|
- lint-prettier
|
||||||
- test
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- run: echo mark
|
||||||
|
4
.github/workflows/ghcr-retention.yml
vendored
4
.github/workflows/ghcr-retention.yml
vendored
@ -11,12 +11,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Delete 'dev' containers older than a week
|
- name: Delete 'dev' containers older than a week
|
||||||
uses: snok/container-retention-policy@v1
|
uses: sondrelg/container-retention-policy@v1
|
||||||
with:
|
with:
|
||||||
image-names: dev-server,dev-ldap,dev-proxy
|
image-names: dev-server,dev-ldap,dev-proxy
|
||||||
cut-off: One week ago UTC
|
cut-off: One week ago UTC
|
||||||
account-type: org
|
account-type: org
|
||||||
org-name: goauthentik
|
org-name: goauthentik
|
||||||
untagged-only: false
|
untagged-only: false
|
||||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
|
||||||
skip-tags: gh-next,gh-main
|
skip-tags: gh-next,gh-main
|
||||||
|
17
.github/workflows/release-publish.yml
vendored
17
.github/workflows/release-publish.yml
vendored
@ -27,11 +27,11 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
secrets: |
|
secrets:
|
||||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||||
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
||||||
tags: |
|
tags: |
|
||||||
@ -75,8 +75,8 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
tags: |
|
tags: |
|
||||||
@ -88,6 +88,9 @@ jobs:
|
|||||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||||
file: ${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
secrets: |
|
||||||
|
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||||
|
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
build-outpost-binary:
|
build-outpost-binary:
|
||||||
@ -106,9 +109,9 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.6.0
|
- uses: actions/setup-node@v3.5.1
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Build web
|
- name: Build web
|
||||||
|
4
.github/workflows/release-tag.yml
vendored
4
.github/workflows/release-tag.yml
vendored
@ -26,14 +26,14 @@ jobs:
|
|||||||
id: get_version
|
id: get_version
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v6
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
|
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: actions/create-release@v1.1.4
|
uses: actions/create-release@v1.1.4
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref }}
|
tag_name: ${{ github.ref }}
|
||||||
release_name: Release ${{ steps.get_version.outputs.result }}
|
release_name: Release ${{ steps.get_version.outputs.result }}
|
||||||
|
4
.github/workflows/translation-compile.yml
vendored
4
.github/workflows/translation-compile.yml
vendored
@ -19,8 +19,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: run compile
|
- name: run compile
|
||||||
@ -29,7 +27,7 @@ jobs:
|
|||||||
uses: peter-evans/create-pull-request@v4
|
uses: peter-evans/create-pull-request@v4
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
branch: compile-backend-translation
|
branch: compile-backend-translation
|
||||||
commit-message: "core: compile backend translations"
|
commit-message: "core: compile backend translations"
|
||||||
title: "core: compile backend translations"
|
title: "core: compile backend translations"
|
||||||
|
18
.github/workflows/web-api-publish.yml
vendored
18
.github/workflows/web-api-publish.yml
vendored
@ -10,11 +10,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3.5.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
node-version: '16'
|
||||||
- uses: actions/setup-node@v3.6.0
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
- name: Generate API Client
|
- name: Generate API Client
|
||||||
run: make gen-client-ts
|
run: make gen-client-ts
|
||||||
@ -30,20 +28,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||||
npm i @goauthentik/api@$VERSION
|
npm i @goauthentik/api@$VERSION
|
||||||
- uses: peter-evans/create-pull-request@v4
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@v4
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
branch: update-web-api-client
|
branch: update-web-api-client
|
||||||
commit-message: "web: bump API Client version"
|
commit-message: "web: bump API Client version"
|
||||||
title: "web: bump API Client version"
|
title: "web: bump API Client version"
|
||||||
body: "web: bump API Client version"
|
body: "web: bump API Client version"
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
signoff: true
|
signoff: true
|
||||||
team-reviewers: "@goauthentik/core"
|
|
||||||
author: authentik bot <github-bot@goauthentik.io>
|
|
||||||
- uses: peter-evans/enable-pull-request-automerge@v2
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
|
||||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
|
||||||
merge-method: squash
|
|
||||||
|
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -14,9 +14,7 @@
|
|||||||
"webauthn",
|
"webauthn",
|
||||||
"traefik",
|
"traefik",
|
||||||
"passwordless",
|
"passwordless",
|
||||||
"kubernetes",
|
"kubernetes"
|
||||||
"sso",
|
|
||||||
"slo"
|
|
||||||
],
|
],
|
||||||
"python.linting.pylintEnabled": true,
|
"python.linting.pylintEnabled": true,
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
@ -46,6 +44,5 @@
|
|||||||
"url": "https://github.com/goauthentik/authentik/issues/<num>",
|
"url": "https://github.com/goauthentik/authentik/issues/<num>",
|
||||||
"ignoreCase": false
|
"ignoreCase": false
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"go.testFlags": ["-count=1"]
|
|
||||||
}
|
}
|
||||||
|
@ -59,18 +59,19 @@ These are the current packages:
|
|||||||
authentik
|
authentik
|
||||||
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
|
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
|
||||||
├── api - General API Configuration (Routes, Schema and general API utilities)
|
├── api - General API Configuration (Routes, Schema and general API utilities)
|
||||||
├── blueprints - Handle managed models and their state.
|
|
||||||
├── core - Core authentik functionality, central routes, core Models
|
├── core - Core authentik functionality, central routes, core Models
|
||||||
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
|
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
|
||||||
├── events - Event Log, middleware and signals to generate signals
|
├── events - Event Log, middleware and signals to generate signals
|
||||||
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
|
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
|
||||||
├── lib - Generic library of functions, few dependencies on other packages.
|
├── lib - Generic library of functions, few dependencies on other packages.
|
||||||
|
├── managed - Handle managed models and their state.
|
||||||
├── outposts - Configure and deploy outposts on kubernetes and docker.
|
├── outposts - Configure and deploy outposts on kubernetes and docker.
|
||||||
├── policies - General PolicyEngine
|
├── policies - General PolicyEngine
|
||||||
│ ├── dummy - A Dummy policy used for testing
|
│ ├── dummy - A Dummy policy used for testing
|
||||||
│ ├── event_matcher - Match events based on different criteria
|
│ ├── event_matcher - Match events based on different criteria
|
||||||
│ ├── expiry - Check when a user's password was last set
|
│ ├── expiry - Check when a user's password was last set
|
||||||
│ ├── expression - Execute any arbitrary python code
|
│ ├── expression - Execute any arbitrary python code
|
||||||
|
│ ├── hibp - Check a password against HaveIBeenPwned
|
||||||
│ ├── password - Check a password against several rules
|
│ ├── password - Check a password against several rules
|
||||||
│ └── reputation - Check the user's/client's reputation
|
│ └── reputation - Check the user's/client's reputation
|
||||||
├── providers
|
├── providers
|
||||||
|
@ -20,7 +20,7 @@ WORKDIR /work/web
|
|||||||
RUN npm ci && npm run build
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
# Stage 3: Poetry to requirements.txt export
|
# Stage 3: Poetry to requirements.txt export
|
||||||
FROM docker.io/python:3.11.2-slim-bullseye AS poetry-locker
|
FROM docker.io/python:3.11.1-slim-bullseye AS poetry-locker
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
COPY ./pyproject.toml /work
|
COPY ./pyproject.toml /work
|
||||||
@ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \
|
|||||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||||
|
|
||||||
# Stage 4: Build go proxy
|
# Stage 4: Build go proxy
|
||||||
FROM docker.io/golang:1.20.1-bullseye AS go-builder
|
FROM docker.io/golang:1.19.4-bullseye AS go-builder
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
|
|
||||||
@ -50,7 +50,6 @@ RUN go build -o /work/authentik ./cmd/server/
|
|||||||
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
|
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
|
||||||
|
|
||||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
||||||
ENV GEOIPUPDATE_VERBOSE="true"
|
|
||||||
|
|
||||||
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||||
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
|
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
|
||||||
@ -58,11 +57,11 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
|||||||
/bin/sh -c "\
|
/bin/sh -c "\
|
||||||
export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
|
export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
|
||||||
export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \
|
export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \
|
||||||
/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0 \
|
/usr/bin/entry.sh || exit 0 \
|
||||||
"
|
"
|
||||||
|
|
||||||
# Stage 6: Run
|
# Stage 6: Run
|
||||||
FROM docker.io/python:3.11.2-slim-bullseye AS final-image
|
FROM docker.io/python:3.11.1-slim-bullseye AS final-image
|
||||||
|
|
||||||
LABEL org.opencontainers.image.url https://goauthentik.io
|
LABEL org.opencontainers.image.url https://goauthentik.io
|
||||||
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
|
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
|
||||||
|
9
Makefile
9
Makefile
@ -6,6 +6,15 @@ NPM_VERSION = $(shell python -m scripts.npm_version)
|
|||||||
|
|
||||||
all: lint-fix lint test gen web
|
all: lint-fix lint test gen web
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
coverage run manage.py test tests/integration
|
||||||
|
|
||||||
|
test-e2e-provider:
|
||||||
|
coverage run manage.py test tests/e2e/test_provider*
|
||||||
|
|
||||||
|
test-e2e-rest:
|
||||||
|
coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source*
|
||||||
|
|
||||||
test-go:
|
test-go:
|
||||||
go test -timeout 0 -v -race -cover ./...
|
go test -timeout 0 -v -race -cover ./...
|
||||||
|
|
||||||
|
12
README.md
12
README.md
@ -38,10 +38,6 @@ See [Development Documentation](https://goauthentik.io/developer-docs/?utm_sourc
|
|||||||
|
|
||||||
See [SECURITY.md](SECURITY.md)
|
See [SECURITY.md](SECURITY.md)
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR!
|
|
||||||
|
|
||||||
## Sponsors
|
## Sponsors
|
||||||
|
|
||||||
This project is proudly sponsored by:
|
This project is proudly sponsored by:
|
||||||
@ -53,3 +49,11 @@ This project is proudly sponsored by:
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
DigitalOcean provides development and testing resources for authentik.
|
DigitalOcean provides development and testing resources for authentik.
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://www.netlify.com">
|
||||||
|
<img src="https://www.netlify.com/img/global/badges/netlify-color-accent.svg" alt="Deploys by Netlify" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Netlify hosts the [goauthentik.io](https://goauthentik.io) site.
|
||||||
|
@ -6,8 +6,8 @@ Authentik takes security very seriously. We follow the rules of [responsible dis
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| --------- | ------------------ |
|
| --------- | ------------------ |
|
||||||
|
| 2022.11.x | :white_check_mark: |
|
||||||
| 2022.12.x | :white_check_mark: |
|
| 2022.12.x | :white_check_mark: |
|
||||||
| 2023.1.x | :white_check_mark: |
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
from os import environ
|
from os import environ
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
__version__ = "2023.2.2"
|
__version__ = "2022.12.3"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,7 +1,4 @@
|
|||||||
"""authentik administration metrics"""
|
"""authentik administration metrics"""
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.db.models.functions import ExtractHour
|
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from rest_framework.fields import IntegerField, SerializerMethodField
|
from rest_framework.fields import IntegerField, SerializerMethodField
|
||||||
@ -24,44 +21,38 @@ class CoordinateSerializer(PassiveSerializer):
|
|||||||
class LoginMetricsSerializer(PassiveSerializer):
|
class LoginMetricsSerializer(PassiveSerializer):
|
||||||
"""Login Metrics per 1h"""
|
"""Login Metrics per 1h"""
|
||||||
|
|
||||||
logins = SerializerMethodField()
|
logins_per_1h = SerializerMethodField()
|
||||||
logins_failed = SerializerMethodField()
|
logins_failed_per_1h = SerializerMethodField()
|
||||||
authorizations = SerializerMethodField()
|
authorizations_per_1h = SerializerMethodField()
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins(self, _):
|
def get_logins_per_1h(self, _):
|
||||||
"""Get successful logins per 8 hours for the last 7 days"""
|
"""Get successful logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
action=EventAction.LOGIN
|
.filter(action=EventAction.LOGIN)
|
||||||
)
|
.get_events_per_hour()
|
||||||
# 3 data points per day, so 8 hour spans
|
|
||||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed(self, _):
|
def get_logins_failed_per_1h(self, _):
|
||||||
"""Get failed logins per 8 hours for the last 7 days"""
|
"""Get failed logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
action=EventAction.LOGIN_FAILED
|
.filter(action=EventAction.LOGIN_FAILED)
|
||||||
)
|
.get_events_per_hour()
|
||||||
# 3 data points per day, so 8 hour spans
|
|
||||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_authorizations(self, _):
|
def get_authorizations_per_1h(self, _):
|
||||||
"""Get successful authorizations per 8 hours for the last 7 days"""
|
"""Get successful authorizations per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
action=EventAction.AUTHORIZE_APPLICATION
|
.filter(action=EventAction.AUTHORIZE_APPLICATION)
|
||||||
)
|
.get_events_per_hour()
|
||||||
# 3 data points per day, so 8 hour spans
|
|
||||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -97,14 +97,8 @@ class SystemView(APIView):
|
|||||||
permission_classes = [IsAdminUser]
|
permission_classes = [IsAdminUser]
|
||||||
pagination_class = None
|
pagination_class = None
|
||||||
filter_backends = []
|
filter_backends = []
|
||||||
serializer_class = SystemSerializer
|
|
||||||
|
|
||||||
@extend_schema(responses={200: SystemSerializer(many=False)})
|
@extend_schema(responses={200: SystemSerializer(many=False)})
|
||||||
def get(self, request: Request) -> Response:
|
def get(self, request: Request) -> Response:
|
||||||
"""Get system information."""
|
"""Get system information."""
|
||||||
return Response(SystemSerializer(request).data)
|
return Response(SystemSerializer(request).data)
|
||||||
|
|
||||||
@extend_schema(responses={200: SystemSerializer(many=False)})
|
|
||||||
def post(self, request: Request) -> Response:
|
|
||||||
"""Get system information."""
|
|
||||||
return Response(SystemSerializer(request).data)
|
|
||||||
|
@ -7,13 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import (
|
from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
|
||||||
CharField,
|
|
||||||
ChoiceField,
|
|
||||||
DateTimeField,
|
|
||||||
ListField,
|
|
||||||
SerializerMethodField,
|
|
||||||
)
|
|
||||||
from rest_framework.permissions import IsAdminUser
|
from rest_framework.permissions import IsAdminUser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -32,7 +26,6 @@ class TaskSerializer(PassiveSerializer):
|
|||||||
task_name = CharField()
|
task_name = CharField()
|
||||||
task_description = CharField()
|
task_description = CharField()
|
||||||
task_finish_timestamp = DateTimeField(source="finish_time")
|
task_finish_timestamp = DateTimeField(source="finish_time")
|
||||||
task_duration = SerializerMethodField()
|
|
||||||
|
|
||||||
status = ChoiceField(
|
status = ChoiceField(
|
||||||
source="result.status.name",
|
source="result.status.name",
|
||||||
@ -40,18 +33,13 @@ class TaskSerializer(PassiveSerializer):
|
|||||||
)
|
)
|
||||||
messages = ListField(source="result.messages")
|
messages = ListField(source="result.messages")
|
||||||
|
|
||||||
def get_task_duration(self, instance: TaskInfo) -> int:
|
def to_representation(self, instance):
|
||||||
"""Get the duration a task took to run"""
|
|
||||||
return max(instance.finish_timestamp - instance.start_timestamp, 0)
|
|
||||||
|
|
||||||
def to_representation(self, instance: TaskInfo):
|
|
||||||
"""When a new version of authentik adds fields to TaskInfo,
|
"""When a new version of authentik adds fields to TaskInfo,
|
||||||
the API will fail with an AttributeError, as the classes
|
the API will fail with an AttributeError, as the classes
|
||||||
are pickled in cache. In that case, just delete the info"""
|
are pickled in cache. In that case, just delete the info"""
|
||||||
try:
|
try:
|
||||||
return super().to_representation(instance)
|
return super().to_representation(instance)
|
||||||
# pylint: disable=broad-except
|
except AttributeError: # pragma: no cover
|
||||||
except Exception: # pragma: no cover
|
|
||||||
if isinstance(self.instance, list):
|
if isinstance(self.instance, list):
|
||||||
for inst in self.instance:
|
for inst in self.instance:
|
||||||
inst.delete()
|
inst.delete()
|
||||||
@ -80,6 +68,7 @@ class TaskViewSet(ViewSet):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def retrieve(self, request: Request, pk=None) -> Response:
|
def retrieve(self, request: Request, pk=None) -> Response:
|
||||||
"""Get a single system task"""
|
"""Get a single system task"""
|
||||||
task = TaskInfo.by_name(pk)
|
task = TaskInfo.by_name(pk)
|
||||||
@ -110,6 +99,7 @@ class TaskViewSet(ViewSet):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def retry(self, request: Request, pk=None) -> Response:
|
def retry(self, request: Request, pk=None) -> Response:
|
||||||
"""Retry task"""
|
"""Retry task"""
|
||||||
task = TaskInfo.by_name(pk)
|
task = TaskInfo.by_name(pk)
|
||||||
|
@ -8,6 +8,7 @@ from authentik.root.monitoring import monitoring_set
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def monitoring_set_workers(sender, **kwargs):
|
def monitoring_set_workers(sender, **kwargs):
|
||||||
"""Set worker gauge"""
|
"""Set worker gauge"""
|
||||||
count = len(CELERY_APP.control.ping(timeout=0.5))
|
count = len(CELERY_APP.control.ping(timeout=0.5))
|
||||||
@ -15,7 +16,8 @@ def monitoring_set_workers(sender, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def monitoring_set_tasks(sender, **kwargs):
|
def monitoring_set_tasks(sender, **kwargs):
|
||||||
"""Set task gauges"""
|
"""Set task gauges"""
|
||||||
for task in TaskInfo.all().values():
|
for task in TaskInfo.all().values():
|
||||||
task.update_metrics()
|
task.set_prom_metrics()
|
||||||
|
@ -32,17 +32,7 @@ def validate_auth(header: bytes) -> Optional[str]:
|
|||||||
|
|
||||||
def bearer_auth(raw_header: bytes) -> Optional[User]:
|
def bearer_auth(raw_header: bytes) -> Optional[User]:
|
||||||
"""raw_header in the Format of `Bearer ....`"""
|
"""raw_header in the Format of `Bearer ....`"""
|
||||||
user = auth_user_lookup(raw_header)
|
from authentik.providers.oauth2.models import RefreshToken
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
if not user.is_active:
|
|
||||||
raise AuthenticationFailed("Token invalid/expired")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def auth_user_lookup(raw_header: bytes) -> Optional[User]:
|
|
||||||
"""raw_header in the Format of `Bearer ....`"""
|
|
||||||
from authentik.providers.oauth2.models import AccessToken
|
|
||||||
|
|
||||||
auth_credentials = validate_auth(raw_header)
|
auth_credentials = validate_auth(raw_header)
|
||||||
if not auth_credentials:
|
if not auth_credentials:
|
||||||
@ -55,8 +45,8 @@ def auth_user_lookup(raw_header: bytes) -> Optional[User]:
|
|||||||
CTX_AUTH_VIA.set("api_token")
|
CTX_AUTH_VIA.set("api_token")
|
||||||
return key_token.user
|
return key_token.user
|
||||||
# then try to auth via JWT
|
# then try to auth via JWT
|
||||||
jwt_token = AccessToken.filter_not_expired(
|
jwt_token = RefreshToken.filter_not_expired(
|
||||||
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||||
).first()
|
).first()
|
||||||
if jwt_token:
|
if jwt_token:
|
||||||
# Double-check scopes, since they are saved in a single string
|
# Double-check scopes, since they are saved in a single string
|
||||||
|
@ -62,7 +62,7 @@ window.addEventListener('DOMContentLoaded', (event) => {
|
|||||||
allow-spec-url-load="false"
|
allow-spec-url-load="false"
|
||||||
allow-spec-file-load="false">
|
allow-spec-file-load="false">
|
||||||
<div slot="nav-logo">
|
<div slot="nav-logo">
|
||||||
<img alt="authentik Logo" class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
<img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
||||||
</div>
|
</div>
|
||||||
</rapi-doc>
|
</rapi-doc>
|
||||||
<script>
|
<script>
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
"""Test API Authentication"""
|
"""Test API Authentication"""
|
||||||
import json
|
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from guardian.shortcuts import get_anonymous_user
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
from authentik.api.authentication import bearer_auth
|
from authentik.api.authentication import bearer_auth
|
||||||
from authentik.blueprints.tests import reconcile_app
|
from authentik.blueprints.tests import reconcile_app
|
||||||
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
|
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
from authentik.core.tests.utils import create_test_flow
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||||
|
|
||||||
|
|
||||||
class TestAPIAuth(TestCase):
|
class TestAPIAuth(TestCase):
|
||||||
@ -36,18 +36,9 @@ class TestAPIAuth(TestCase):
|
|||||||
|
|
||||||
def test_bearer_valid(self):
|
def test_bearer_valid(self):
|
||||||
"""Test valid token"""
|
"""Test valid token"""
|
||||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=create_test_admin_user())
|
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user())
|
||||||
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
||||||
|
|
||||||
def test_bearer_valid_deactivated(self):
|
|
||||||
"""Test valid token"""
|
|
||||||
user = create_test_admin_user()
|
|
||||||
user.is_active = False
|
|
||||||
user.save()
|
|
||||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=user)
|
|
||||||
with self.assertRaises(AuthenticationFailed):
|
|
||||||
bearer_auth(f"Bearer {token.key}".encode())
|
|
||||||
|
|
||||||
def test_managed_outpost(self):
|
def test_managed_outpost(self):
|
||||||
"""Test managed outpost"""
|
"""Test managed outpost"""
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
@ -64,26 +55,24 @@ class TestAPIAuth(TestCase):
|
|||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||||
)
|
)
|
||||||
refresh = AccessToken.objects.create(
|
refresh = RefreshToken.objects.create(
|
||||||
user=create_test_admin_user(),
|
user=get_anonymous_user(),
|
||||||
provider=provider,
|
provider=provider,
|
||||||
token=generate_id(),
|
refresh_token=generate_id(),
|
||||||
_scope=SCOPE_AUTHENTIK_API,
|
_scope=SCOPE_AUTHENTIK_API,
|
||||||
_id_token=json.dumps({}),
|
|
||||||
)
|
)
|
||||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||||
|
|
||||||
def test_jwt_missing_scope(self):
|
def test_jwt_missing_scope(self):
|
||||||
"""Test valid JWT"""
|
"""Test valid JWT"""
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||||
)
|
)
|
||||||
refresh = AccessToken.objects.create(
|
refresh = RefreshToken.objects.create(
|
||||||
user=create_test_admin_user(),
|
user=get_anonymous_user(),
|
||||||
provider=provider,
|
provider=provider,
|
||||||
token=generate_id(),
|
refresh_token=generate_id(),
|
||||||
_scope="",
|
_scope="",
|
||||||
_id_token=json.dumps({}),
|
|
||||||
)
|
)
|
||||||
with self.assertRaises(AuthenticationFailed):
|
with self.assertRaises(AuthenticationFailed):
|
||||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||||
|
@ -45,16 +45,13 @@ from authentik.policies.dummy.api import DummyPolicyViewSet
|
|||||||
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
|
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
|
||||||
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
|
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
|
||||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
||||||
|
from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
|
||||||
from authentik.policies.password.api import PasswordPolicyViewSet
|
from authentik.policies.password.api import PasswordPolicyViewSet
|
||||||
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
|
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
|
||||||
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
||||||
from authentik.providers.oauth2.api.providers import OAuth2ProviderViewSet
|
from authentik.providers.oauth2.api.providers import OAuth2ProviderViewSet
|
||||||
from authentik.providers.oauth2.api.scopes import ScopeMappingViewSet
|
from authentik.providers.oauth2.api.scopes import ScopeMappingViewSet
|
||||||
from authentik.providers.oauth2.api.tokens import (
|
from authentik.providers.oauth2.api.tokens import AuthorizationCodeViewSet, RefreshTokenViewSet
|
||||||
AccessTokenViewSet,
|
|
||||||
AuthorizationCodeViewSet,
|
|
||||||
RefreshTokenViewSet,
|
|
||||||
)
|
|
||||||
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
|
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
|
||||||
from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet
|
from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet
|
||||||
from authentik.providers.saml.api.providers import SAMLProviderViewSet
|
from authentik.providers.saml.api.providers import SAMLProviderViewSet
|
||||||
@ -153,6 +150,7 @@ router.register("policies/all", PolicyViewSet)
|
|||||||
router.register("policies/bindings", PolicyBindingViewSet)
|
router.register("policies/bindings", PolicyBindingViewSet)
|
||||||
router.register("policies/expression", ExpressionPolicyViewSet)
|
router.register("policies/expression", ExpressionPolicyViewSet)
|
||||||
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
|
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
|
||||||
|
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
||||||
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
||||||
router.register("policies/password", PasswordPolicyViewSet)
|
router.register("policies/password", PasswordPolicyViewSet)
|
||||||
router.register("policies/reputation/scores", ReputationViewSet)
|
router.register("policies/reputation/scores", ReputationViewSet)
|
||||||
@ -166,7 +164,6 @@ router.register("providers/saml", SAMLProviderViewSet)
|
|||||||
|
|
||||||
router.register("oauth2/authorization_codes", AuthorizationCodeViewSet)
|
router.register("oauth2/authorization_codes", AuthorizationCodeViewSet)
|
||||||
router.register("oauth2/refresh_tokens", RefreshTokenViewSet)
|
router.register("oauth2/refresh_tokens", RefreshTokenViewSet)
|
||||||
router.register("oauth2/access_tokens", AccessTokenViewSet)
|
|
||||||
|
|
||||||
router.register("propertymappings/all", PropertyMappingViewSet)
|
router.register("propertymappings/all", PropertyMappingViewSet)
|
||||||
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""Serializer mixin for managed models"""
|
"""Serializer mixin for managed models"""
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
@ -12,7 +11,6 @@ from rest_framework.viewsets import ModelViewSet
|
|||||||
|
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||||
from authentik.blueprints.v1.importer import Importer
|
|
||||||
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
@ -42,22 +40,8 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
|||||||
raise ValidationError(exc) from exc
|
raise ValidationError(exc) from exc
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def validate_content(self, content: str) -> str:
|
|
||||||
"""Ensure content (if set) is a valid blueprint"""
|
|
||||||
if content == "":
|
|
||||||
return content
|
|
||||||
context = self.instance.context if self.instance else {}
|
|
||||||
valid, logs = Importer(content, context).validate()
|
|
||||||
if not valid:
|
|
||||||
raise ValidationError(_("Failed to validate blueprint"), *[x["msg"] for x in logs])
|
|
||||||
return content
|
|
||||||
|
|
||||||
def validate(self, attrs: dict) -> dict:
|
|
||||||
if attrs.get("path", "") == "" and attrs.get("content", "") == "":
|
|
||||||
raise ValidationError(_("Either path or content must be set."))
|
|
||||||
return super().validate(attrs)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = BlueprintInstance
|
model = BlueprintInstance
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -70,7 +54,6 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
|||||||
"enabled",
|
"enabled",
|
||||||
"managed_models",
|
"managed_models",
|
||||||
"metadata",
|
"metadata",
|
||||||
"content",
|
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"status": {"read_only": True},
|
"status": {"read_only": True},
|
||||||
|
@ -57,10 +57,9 @@ class AuthentikBlueprintsConfig(ManagedAppConfig):
|
|||||||
|
|
||||||
def reconcile_blueprints_discover(self):
|
def reconcile_blueprints_discover(self):
|
||||||
"""Run blueprint discovery"""
|
"""Run blueprint discovery"""
|
||||||
from authentik.blueprints.v1.tasks import blueprints_discover, clear_failed_blueprints
|
from authentik.blueprints.v1.tasks import blueprints_discover
|
||||||
|
|
||||||
blueprints_discover.delay()
|
blueprints_discover.delay()
|
||||||
clear_failed_blueprints.delay()
|
|
||||||
|
|
||||||
def import_models(self):
|
def import_models(self):
|
||||||
super().import_models()
|
super().import_models()
|
||||||
|
@ -71,6 +71,7 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [("authentik_flows", "0001_initial")]
|
dependencies = [("authentik_flows", "0001_initial")]
|
||||||
@ -85,12 +86,7 @@ class Migration(migrations.Migration):
|
|||||||
"managed",
|
"managed",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
default=None,
|
default=None,
|
||||||
help_text=(
|
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||||
"Objects which are managed by authentik. These objects are created and"
|
|
||||||
" updated automatically. This is flag only indicates that an object can"
|
|
||||||
" be overwritten by migrations. You can still modify the objects via"
|
|
||||||
" the API, but expect changes to be overwritten in a later update."
|
|
||||||
),
|
|
||||||
null=True,
|
null=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="Managed by authentik",
|
verbose_name="Managed by authentik",
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
# Generated by Django 4.1.5 on 2023-01-10 19:48
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("authentik_blueprints", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="blueprintinstance",
|
|
||||||
name="content",
|
|
||||||
field=models.TextField(blank=True, default=""),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="blueprintinstance",
|
|
||||||
name="path",
|
|
||||||
field=models.TextField(blank=True, default=""),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,18 +1,30 @@
|
|||||||
"""blueprint models"""
|
"""blueprint models"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from opencontainers.distribution.reggie import (
|
||||||
|
NewClient,
|
||||||
|
WithDebug,
|
||||||
|
WithDefaultName,
|
||||||
|
WithDigest,
|
||||||
|
WithReference,
|
||||||
|
WithUserAgent,
|
||||||
|
WithUsernamePassword,
|
||||||
|
)
|
||||||
|
from requests.exceptions import RequestException
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from authentik.blueprints.v1.oci import BlueprintOCIClient, OCIException
|
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
|
from authentik.lib.utils.http import authentik_user_agent
|
||||||
|
|
||||||
|
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@ -29,15 +41,18 @@ class ManagedModel(models.Model):
|
|||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Managed by authentik"),
|
verbose_name=_("Managed by authentik"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"Objects which are managed by authentik. These objects are created and updated "
|
(
|
||||||
"automatically. This is flag only indicates that an object can be overwritten by "
|
"Objects which are managed by authentik. These objects are created and updated "
|
||||||
"migrations. You can still modify the objects via the API, but expect changes "
|
"automatically. This is flag only indicates that an object can be overwritten by "
|
||||||
"to be overwritten in a later update."
|
"migrations. You can still modify the objects via the API, but expect changes "
|
||||||
|
"to be overwritten in a later update."
|
||||||
|
)
|
||||||
),
|
),
|
||||||
unique=True,
|
unique=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
@ -59,8 +74,7 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
name = models.TextField()
|
name = models.TextField()
|
||||||
metadata = models.JSONField(default=dict)
|
metadata = models.JSONField(default=dict)
|
||||||
path = models.TextField(default="", blank=True)
|
path = models.TextField()
|
||||||
content = models.TextField(default="", blank=True)
|
|
||||||
context = models.JSONField(default=dict)
|
context = models.JSONField(default=dict)
|
||||||
last_applied = models.DateTimeField(auto_now=True)
|
last_applied = models.DateTimeField(auto_now=True)
|
||||||
last_applied_hash = models.TextField()
|
last_applied_hash = models.TextField()
|
||||||
@ -72,29 +86,60 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
def retrieve_oci(self) -> str:
|
def retrieve_oci(self) -> str:
|
||||||
"""Get blueprint from an OCI registry"""
|
"""Get blueprint from an OCI registry"""
|
||||||
client = BlueprintOCIClient(self.path.replace("oci://", "https://"))
|
url = urlparse(self.path)
|
||||||
|
ref = "latest"
|
||||||
|
path = url.path[1:]
|
||||||
|
if ":" in url.path:
|
||||||
|
path, _, ref = path.partition(":")
|
||||||
|
client = NewClient(
|
||||||
|
f"https://{url.hostname}",
|
||||||
|
WithUserAgent(authentik_user_agent()),
|
||||||
|
WithUsernamePassword(url.username, url.password),
|
||||||
|
WithDefaultName(path),
|
||||||
|
WithDebug(True),
|
||||||
|
)
|
||||||
|
LOGGER.info("Fetching OCI manifests for blueprint", instance=self)
|
||||||
|
manifest_request = client.NewRequest(
|
||||||
|
"GET",
|
||||||
|
"/v2/<name>/manifests/<reference>",
|
||||||
|
WithReference(ref),
|
||||||
|
).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
|
||||||
try:
|
try:
|
||||||
manifests = client.fetch_manifests()
|
manifest_response = client.Do(manifest_request)
|
||||||
return client.fetch_blobs(manifests)
|
manifest_response.raise_for_status()
|
||||||
except OCIException as exc:
|
except RequestException as exc:
|
||||||
raise BlueprintRetrievalFailed(exc) from exc
|
raise BlueprintRetrievalFailed(exc) from exc
|
||||||
|
manifest = manifest_response.json()
|
||||||
|
if "errors" in manifest:
|
||||||
|
raise BlueprintRetrievalFailed(manifest["errors"])
|
||||||
|
|
||||||
def retrieve_file(self) -> str:
|
blob = None
|
||||||
"""Get blueprint from path"""
|
for layer in manifest.get("layers", []):
|
||||||
|
if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
|
||||||
|
blob = layer.get("digest")
|
||||||
|
LOGGER.debug("Found layer with matching media type", instance=self, blob=blob)
|
||||||
|
if not blob:
|
||||||
|
raise BlueprintRetrievalFailed("Blob not found")
|
||||||
|
|
||||||
|
blob_request = client.NewRequest(
|
||||||
|
"GET",
|
||||||
|
"/v2/<name>/blobs/<digest>",
|
||||||
|
WithDigest(blob),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
blob_response = client.Do(blob_request)
|
||||||
with full_path.open("r", encoding="utf-8") as _file:
|
blob_response.raise_for_status()
|
||||||
return _file.read()
|
return blob_response.text
|
||||||
except (IOError, OSError) as exc:
|
except RequestException as exc:
|
||||||
raise BlueprintRetrievalFailed(exc) from exc
|
raise BlueprintRetrievalFailed(exc) from exc
|
||||||
|
|
||||||
def retrieve(self) -> str:
|
def retrieve(self) -> str:
|
||||||
"""Retrieve blueprint contents"""
|
"""Retrieve blueprint contents"""
|
||||||
if self.path.startswith("oci://"):
|
if self.path.startswith("oci://"):
|
||||||
return self.retrieve_oci()
|
return self.retrieve_oci()
|
||||||
if self.path != "":
|
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
||||||
return self.retrieve_file()
|
with full_path.open("r", encoding="utf-8") as _file:
|
||||||
return self.content
|
return _file.read()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> Serializer:
|
def serializer(self) -> Serializer:
|
||||||
@ -106,6 +151,7 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
|||||||
return f"Blueprint Instance {self.name}"
|
return f"Blueprint Instance {self.name}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Blueprint Instance")
|
verbose_name = _("Blueprint Instance")
|
||||||
verbose_name_plural = _("Blueprint Instances")
|
verbose_name_plural = _("Blueprint Instances")
|
||||||
unique_together = (
|
unique_together = (
|
||||||
|
@ -9,9 +9,4 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
"schedule": crontab(minute=fqdn_rand("blueprints_v1_discover"), hour="*"),
|
"schedule": crontab(minute=fqdn_rand("blueprints_v1_discover"), hour="*"),
|
||||||
"options": {"queue": "authentik_scheduled"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
"blueprints_v1_cleanup": {
|
|
||||||
"task": "authentik.blueprints.v1.tasks.clear_failed_blueprints",
|
|
||||||
"schedule": crontab(minute=fqdn_rand("blueprints_v1_cleanup"), hour="*"),
|
|
||||||
"options": {"queue": "authentik_scheduled"},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ entries:
|
|||||||
pk: cb954fd4-65a5-4ad9-b1ee-180ee9559cf4
|
pk: cb954fd4-65a5-4ad9-b1ee-180ee9559cf4
|
||||||
model: authentik_stages_prompt.prompt
|
model: authentik_stages_prompt.prompt
|
||||||
attrs:
|
attrs:
|
||||||
name: qwerweqrq
|
|
||||||
field_key: username
|
field_key: username
|
||||||
label: Username
|
label: Username
|
||||||
type: username
|
type: username
|
||||||
|
51
authentik/blueprints/tests/fixtures/tags.yaml
vendored
51
authentik/blueprints/tests/fixtures/tags.yaml
vendored
@ -3,12 +3,6 @@ context:
|
|||||||
foo: bar
|
foo: bar
|
||||||
policy_property: name
|
policy_property: name
|
||||||
policy_property_value: foo-bar-baz-qux
|
policy_property_value: foo-bar-baz-qux
|
||||||
sequence:
|
|
||||||
- foo
|
|
||||||
- bar
|
|
||||||
mapping:
|
|
||||||
key1: value
|
|
||||||
key2: 2
|
|
||||||
entries:
|
entries:
|
||||||
- model: !Format ["%s", authentik_sources_oauth.oauthsource]
|
- model: !Format ["%s", authentik_sources_oauth.oauthsource]
|
||||||
state: !Format ["%s", present]
|
state: !Format ["%s", present]
|
||||||
@ -25,7 +19,7 @@ entries:
|
|||||||
[slug, default-source-authentication],
|
[slug, default-source-authentication],
|
||||||
]
|
]
|
||||||
enrollment_flow:
|
enrollment_flow:
|
||||||
!Find [!Format ["%s", authentik_flows.Flow], [slug, default-source-enrollment]]
|
!Find [authentik_flows.Flow, [slug, default-source-enrollment]]
|
||||||
- attrs:
|
- attrs:
|
||||||
expression: return True
|
expression: return True
|
||||||
identifiers:
|
identifiers:
|
||||||
@ -98,49 +92,6 @@ entries:
|
|||||||
]
|
]
|
||||||
if_true_simple: !If [!Context foo, true, text]
|
if_true_simple: !If [!Context foo, true, text]
|
||||||
if_false_simple: !If [null, false, 2]
|
if_false_simple: !If [null, false, 2]
|
||||||
enumerate_mapping_to_mapping: !Enumerate [
|
|
||||||
!Context mapping,
|
|
||||||
MAP,
|
|
||||||
[!Format ["prefix-%s", !Index 0], !Format ["other-prefix-%s", !Value 0]]
|
|
||||||
]
|
|
||||||
enumerate_mapping_to_sequence: !Enumerate [
|
|
||||||
!Context mapping,
|
|
||||||
SEQ,
|
|
||||||
!Format ["prefixed-pair-%s-%s", !Index 0, !Value 0]
|
|
||||||
]
|
|
||||||
enumerate_sequence_to_sequence: !Enumerate [
|
|
||||||
!Context sequence,
|
|
||||||
SEQ,
|
|
||||||
!Format ["prefixed-items-%s-%s", !Index 0, !Value 0]
|
|
||||||
]
|
|
||||||
enumerate_sequence_to_mapping: !Enumerate [
|
|
||||||
!Context sequence,
|
|
||||||
MAP,
|
|
||||||
[!Format ["index: %d", !Index 0], !Value 0]
|
|
||||||
]
|
|
||||||
nested_complex_enumeration: !Enumerate [
|
|
||||||
!Context sequence,
|
|
||||||
MAP,
|
|
||||||
[
|
|
||||||
!Index 0,
|
|
||||||
!Enumerate [
|
|
||||||
!Context mapping,
|
|
||||||
MAP,
|
|
||||||
[
|
|
||||||
!Format ["%s", !Index 0],
|
|
||||||
[
|
|
||||||
!Enumerate [!Value 2, SEQ, !Format ["prefixed-%s", !Value 0]],
|
|
||||||
{
|
|
||||||
outer_value: !Value 1,
|
|
||||||
outer_index: !Index 1,
|
|
||||||
middle_value: !Value 0,
|
|
||||||
middle_index: !Index 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
identifiers:
|
identifiers:
|
||||||
name: test
|
name: test
|
||||||
conditions:
|
conditions:
|
||||||
|
@ -2,8 +2,7 @@
|
|||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
from requests_mock import Mocker
|
from requests_mock import Mocker
|
||||||
|
|
||||||
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
from authentik.blueprints.models import OCI_MEDIA_TYPE, BlueprintInstance, BlueprintRetrievalFailed
|
||||||
from authentik.blueprints.v1.oci import OCI_MEDIA_TYPE
|
|
||||||
|
|
||||||
|
|
||||||
class TestBlueprintOCI(TransactionTestCase):
|
class TestBlueprintOCI(TransactionTestCase):
|
||||||
|
@ -13,7 +13,7 @@ from authentik.tenants.models import Tenant
|
|||||||
class TestPackaged(TransactionTestCase):
|
class TestPackaged(TransactionTestCase):
|
||||||
"""Empty class, test methods are added dynamically"""
|
"""Empty class, test methods are added dynamically"""
|
||||||
|
|
||||||
@apply_blueprint("default/default-tenant.yaml")
|
@apply_blueprint("default/90-default-tenant.yaml")
|
||||||
def test_decorator_static(self):
|
def test_decorator_static(self):
|
||||||
"""Test @apply_blueprint decorator"""
|
"""Test @apply_blueprint decorator"""
|
||||||
self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
|
self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
|
||||||
|
@ -24,14 +24,18 @@ class TestBlueprintsV1(TransactionTestCase):
|
|||||||
importer = Importer('{"version": 3}')
|
importer = Importer('{"version": 3}')
|
||||||
self.assertFalse(importer.validate()[0])
|
self.assertFalse(importer.validate()[0])
|
||||||
importer = Importer(
|
importer = Importer(
|
||||||
'{"version": 1,"entries":[{"identifiers":{},"attrs":{},'
|
(
|
||||||
'"model": "authentik_core.User"}]}'
|
'{"version": 1,"entries":[{"identifiers":{},"attrs":{},'
|
||||||
|
'"model": "authentik_core.User"}]}'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.assertFalse(importer.validate()[0])
|
self.assertFalse(importer.validate()[0])
|
||||||
importer = Importer(
|
importer = Importer(
|
||||||
'{"version": 1, "entries": [{"attrs": {"name": "test"}, '
|
(
|
||||||
'"identifiers": {}, '
|
'{"version": 1, "entries": [{"attrs": {"name": "test"}, '
|
||||||
'"model": "authentik_core.Group"}]}'
|
'"identifiers": {}, '
|
||||||
|
'"model": "authentik_core.Group"}]}'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.assertFalse(importer.validate()[0])
|
self.assertFalse(importer.validate()[0])
|
||||||
|
|
||||||
@ -55,9 +59,11 @@ class TestBlueprintsV1(TransactionTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
importer = Importer(
|
importer = Importer(
|
||||||
'{"version": 1, "entries": [{"attrs": {"name": "test999", "attributes": '
|
(
|
||||||
'{"key": ["updated_value"]}}, "identifiers": {"attributes": {"other_key": '
|
'{"version": 1, "entries": [{"attrs": {"name": "test999", "attributes": '
|
||||||
'["other_value"]}}, "model": "authentik_core.Group"}]}'
|
'{"key": ["updated_value"]}}, "identifiers": {"attributes": {"other_key": '
|
||||||
|
'["other_value"]}}, "model": "authentik_core.Group"}]}'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.assertTrue(importer.validate()[0])
|
self.assertTrue(importer.validate()[0])
|
||||||
self.assertTrue(importer.apply())
|
self.assertTrue(importer.apply())
|
||||||
@ -156,61 +162,6 @@ class TestBlueprintsV1(TransactionTestCase):
|
|||||||
"if_false_complex": ["list", "with", "items", "foo-bar"],
|
"if_false_complex": ["list", "with", "items", "foo-bar"],
|
||||||
"if_true_simple": True,
|
"if_true_simple": True,
|
||||||
"if_false_simple": 2,
|
"if_false_simple": 2,
|
||||||
"enumerate_mapping_to_mapping": {
|
|
||||||
"prefix-key1": "other-prefix-value",
|
|
||||||
"prefix-key2": "other-prefix-2",
|
|
||||||
},
|
|
||||||
"enumerate_mapping_to_sequence": [
|
|
||||||
"prefixed-pair-key1-value",
|
|
||||||
"prefixed-pair-key2-2",
|
|
||||||
],
|
|
||||||
"enumerate_sequence_to_sequence": [
|
|
||||||
"prefixed-items-0-foo",
|
|
||||||
"prefixed-items-1-bar",
|
|
||||||
],
|
|
||||||
"enumerate_sequence_to_mapping": {"index: 0": "foo", "index: 1": "bar"},
|
|
||||||
"nested_complex_enumeration": {
|
|
||||||
"0": {
|
|
||||||
"key1": [
|
|
||||||
["prefixed-f", "prefixed-o", "prefixed-o"],
|
|
||||||
{
|
|
||||||
"outer_value": "foo",
|
|
||||||
"outer_index": 0,
|
|
||||||
"middle_value": "value",
|
|
||||||
"middle_index": "key1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"key2": [
|
|
||||||
["prefixed-f", "prefixed-o", "prefixed-o"],
|
|
||||||
{
|
|
||||||
"outer_value": "foo",
|
|
||||||
"outer_index": 0,
|
|
||||||
"middle_value": 2,
|
|
||||||
"middle_index": "key2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"1": {
|
|
||||||
"key1": [
|
|
||||||
["prefixed-b", "prefixed-a", "prefixed-r"],
|
|
||||||
{
|
|
||||||
"outer_value": "bar",
|
|
||||||
"outer_index": 1,
|
|
||||||
"middle_value": "value",
|
|
||||||
"middle_index": "key1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"key2": [
|
|
||||||
["prefixed-b", "prefixed-a", "prefixed-r"],
|
|
||||||
{
|
|
||||||
"outer_value": "bar",
|
|
||||||
"outer_index": 1,
|
|
||||||
"middle_value": 2,
|
|
||||||
"middle_index": "key2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -256,21 +207,15 @@ class TestBlueprintsV1(TransactionTestCase):
|
|||||||
with transaction_rollback():
|
with transaction_rollback():
|
||||||
# First stage fields
|
# First stage fields
|
||||||
username_prompt = Prompt.objects.create(
|
username_prompt = Prompt.objects.create(
|
||||||
name=generate_id(),
|
field_key="username", label="Username", order=0, type=FieldTypes.TEXT
|
||||||
field_key="username",
|
|
||||||
label="Username",
|
|
||||||
order=0,
|
|
||||||
type=FieldTypes.TEXT,
|
|
||||||
)
|
)
|
||||||
password = Prompt.objects.create(
|
password = Prompt.objects.create(
|
||||||
name=generate_id(),
|
|
||||||
field_key="password",
|
field_key="password",
|
||||||
label="Password",
|
label="Password",
|
||||||
order=1,
|
order=1,
|
||||||
type=FieldTypes.PASSWORD,
|
type=FieldTypes.PASSWORD,
|
||||||
)
|
)
|
||||||
password_repeat = Prompt.objects.create(
|
password_repeat = Prompt.objects.create(
|
||||||
name=generate_id(),
|
|
||||||
field_key="password_repeat",
|
field_key="password_repeat",
|
||||||
label="Password (repeat)",
|
label="Password (repeat)",
|
||||||
order=2,
|
order=2,
|
||||||
|
@ -43,28 +43,3 @@ class TestBlueprintsV1API(APITestCase):
|
|||||||
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_api_blank(self):
|
|
||||||
"""Test blank"""
|
|
||||||
res = self.client.post(
|
|
||||||
reverse("authentik_api:blueprintinstance-list"),
|
|
||||||
data={
|
|
||||||
"name": "foo",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(res.status_code, 400)
|
|
||||||
self.assertJSONEqual(
|
|
||||||
res.content.decode(), {"non_field_errors": ["Either path or content must be set."]}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_api_content(self):
|
|
||||||
"""Test blank"""
|
|
||||||
res = self.client.post(
|
|
||||||
reverse("authentik_api:blueprintinstance-list"),
|
|
||||||
data={
|
|
||||||
"name": "foo",
|
|
||||||
"content": '{"version": 3}',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(res.status_code, 400)
|
|
||||||
self.assertJSONEqual(res.content.decode(), {"content": ["Failed to validate blueprint"]})
|
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
"""transfer common classes"""
|
"""transfer common classes"""
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from copy import copy
|
|
||||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from operator import ixor
|
from operator import ixor
|
||||||
from os import getenv
|
from os import getenv
|
||||||
from typing import Any, Iterable, Literal, Mapping, Optional, Union
|
from typing import Any, Literal, Optional, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from deepmerge import always_merger
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db.models import Model, Q
|
from django.db.models import Model, Q
|
||||||
from rest_framework.fields import Field
|
from rest_framework.fields import Field
|
||||||
@ -66,13 +64,11 @@ class BlueprintEntry:
|
|||||||
identifiers: dict[str, Any] = field(default_factory=dict)
|
identifiers: dict[str, Any] = field(default_factory=dict)
|
||||||
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
|
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
|
||||||
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
|
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
|
||||||
|
|
||||||
def __post_init__(self, *args, **kwargs) -> None:
|
|
||||||
self.__tag_contexts: list["YAMLTagContext"] = []
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
|
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
|
||||||
"""Convert a SerializerModel instance to a blueprint Entry"""
|
"""Convert a SerializerModel instance to a blueprint Entry"""
|
||||||
@ -89,46 +85,17 @@ class BlueprintEntry:
|
|||||||
attrs=all_attrs,
|
attrs=all_attrs,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_tag_context(
|
|
||||||
self,
|
|
||||||
depth: int = 0,
|
|
||||||
context_tag_type: Optional[type["YAMLTagContext"] | tuple["YAMLTagContext", ...]] = None,
|
|
||||||
) -> "YAMLTagContext":
|
|
||||||
"""Get a YAMLTagContext object located at a certain depth in the tag tree"""
|
|
||||||
if depth < 0:
|
|
||||||
raise ValueError("depth must be a positive number or zero")
|
|
||||||
|
|
||||||
if context_tag_type:
|
|
||||||
contexts = [x for x in self.__tag_contexts if isinstance(x, context_tag_type)]
|
|
||||||
else:
|
|
||||||
contexts = self.__tag_contexts
|
|
||||||
|
|
||||||
try:
|
|
||||||
return contexts[-(depth + 1)]
|
|
||||||
except IndexError:
|
|
||||||
raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}")
|
|
||||||
|
|
||||||
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
|
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
|
||||||
"""Check if we have any special tags that need handling"""
|
"""Check if we have any special tags that need handling"""
|
||||||
val = copy(value)
|
|
||||||
|
|
||||||
if isinstance(value, YAMLTagContext):
|
|
||||||
self.__tag_contexts.append(value)
|
|
||||||
|
|
||||||
if isinstance(value, YAMLTag):
|
if isinstance(value, YAMLTag):
|
||||||
val = value.resolve(self, blueprint)
|
return value.resolve(self, blueprint)
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for key, inner_value in value.items():
|
for key, inner_value in value.items():
|
||||||
val[key] = self.tag_resolver(inner_value, blueprint)
|
value[key] = self.tag_resolver(inner_value, blueprint)
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
for idx, inner_value in enumerate(value):
|
for idx, inner_value in enumerate(value):
|
||||||
val[idx] = self.tag_resolver(inner_value, blueprint)
|
value[idx] = self.tag_resolver(inner_value, blueprint)
|
||||||
|
return value
|
||||||
if isinstance(value, YAMLTagContext):
|
|
||||||
self.__tag_contexts.pop()
|
|
||||||
|
|
||||||
return val
|
|
||||||
|
|
||||||
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
|
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
|
||||||
"""Get attributes of this entry, with all yaml tags resolved"""
|
"""Get attributes of this entry, with all yaml tags resolved"""
|
||||||
@ -178,19 +145,12 @@ class YAMLTag:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class YAMLTagContext:
|
|
||||||
"""Base class for all YAML Tag Contexts"""
|
|
||||||
|
|
||||||
def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
|
||||||
"""Implement yaml tag context logic"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class KeyOf(YAMLTag):
|
class KeyOf(YAMLTag):
|
||||||
"""Reference another object by their ID"""
|
"""Reference another object by their ID"""
|
||||||
|
|
||||||
id_from: str
|
id_from: str
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.id_from = node.value
|
self.id_from = node.value
|
||||||
@ -217,6 +177,7 @@ class Env(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Optional[Any]
|
default: Optional[Any]
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
@ -236,6 +197,7 @@ class Context(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Optional[Any]
|
default: Optional[Any]
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
@ -258,6 +220,7 @@ class Format(YAMLTag):
|
|||||||
format_string: str
|
format_string: str
|
||||||
args: list[Any]
|
args: list[Any]
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.format_string = node.value[0].value
|
self.format_string = node.value[0].value
|
||||||
@ -282,12 +245,15 @@ class Format(YAMLTag):
|
|||||||
class Find(YAMLTag):
|
class Find(YAMLTag):
|
||||||
"""Find any object"""
|
"""Find any object"""
|
||||||
|
|
||||||
model_name: str | YAMLTag
|
model_name: str
|
||||||
conditions: list[list]
|
conditions: list[list]
|
||||||
|
|
||||||
|
model_class: type[Model]
|
||||||
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.model_name = loader.construct_object(node.value[0])
|
self.model_name = node.value[0].value
|
||||||
|
self.model_class = apps.get_model(*self.model_name.split("."))
|
||||||
self.conditions = []
|
self.conditions = []
|
||||||
for raw_node in node.value[1:]:
|
for raw_node in node.value[1:]:
|
||||||
values = []
|
values = []
|
||||||
@ -296,13 +262,6 @@ class Find(YAMLTag):
|
|||||||
self.conditions.append(values)
|
self.conditions.append(values)
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
if isinstance(self.model_name, YAMLTag):
|
|
||||||
model_name = self.model_name.resolve(entry, blueprint)
|
|
||||||
else:
|
|
||||||
model_name = self.model_name
|
|
||||||
|
|
||||||
model_class = apps.get_model(*model_name.split("."))
|
|
||||||
|
|
||||||
query = Q()
|
query = Q()
|
||||||
for cond in self.conditions:
|
for cond in self.conditions:
|
||||||
if isinstance(cond[0], YAMLTag):
|
if isinstance(cond[0], YAMLTag):
|
||||||
@ -314,7 +273,7 @@ class Find(YAMLTag):
|
|||||||
else:
|
else:
|
||||||
query_value = cond[1]
|
query_value = cond[1]
|
||||||
query &= Q(**{query_key: query_value})
|
query &= Q(**{query_key: query_value})
|
||||||
instance = model_class.objects.filter(query).first()
|
instance = self.model_class.objects.filter(query).first()
|
||||||
if instance:
|
if instance:
|
||||||
return instance.pk
|
return instance.pk
|
||||||
return None
|
return None
|
||||||
@ -337,6 +296,7 @@ class Condition(YAMLTag):
|
|||||||
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
|
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.mode = node.value[0].value
|
self.mode = node.value[0].value
|
||||||
@ -369,6 +329,7 @@ class If(YAMLTag):
|
|||||||
when_true: Any
|
when_true: Any
|
||||||
when_false: Any
|
when_false: Any
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.condition = loader.construct_object(node.value[0])
|
self.condition = loader.construct_object(node.value[0])
|
||||||
@ -390,133 +351,6 @@ class If(YAMLTag):
|
|||||||
raise EntryInvalidError(exc)
|
raise EntryInvalidError(exc)
|
||||||
|
|
||||||
|
|
||||||
class Enumerate(YAMLTag, YAMLTagContext):
|
|
||||||
"""Iterate over an iterable."""
|
|
||||||
|
|
||||||
iterable: YAMLTag | Iterable
|
|
||||||
item_body: Any
|
|
||||||
output_body: Literal["SEQ", "MAP"]
|
|
||||||
|
|
||||||
_OUTPUT_BODIES = {
|
|
||||||
"SEQ": (list, lambda a, b: [*a, b]),
|
|
||||||
"MAP": (
|
|
||||||
dict,
|
|
||||||
lambda a, b: always_merger.merge(
|
|
||||||
a, {b[0]: b[1]} if isinstance(b, (tuple, list)) else b
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.iterable = loader.construct_object(node.value[0])
|
|
||||||
self.output_body = node.value[1].value
|
|
||||||
self.item_body = loader.construct_object(node.value[2])
|
|
||||||
self.__current_context: tuple[Any, Any] = tuple()
|
|
||||||
|
|
||||||
def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
|
||||||
return self.__current_context
|
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
|
||||||
if isinstance(self.iterable, EnumeratedItem) and self.iterable.depth == 0:
|
|
||||||
raise EntryInvalidError(
|
|
||||||
f"{self.__class__.__name__} tag's iterable references this tag's context. "
|
|
||||||
"This is a noop. Check you are setting depth bigger than 0."
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(self.iterable, YAMLTag):
|
|
||||||
iterable = self.iterable.resolve(entry, blueprint)
|
|
||||||
else:
|
|
||||||
iterable = self.iterable
|
|
||||||
|
|
||||||
if not isinstance(iterable, Iterable):
|
|
||||||
raise EntryInvalidError(
|
|
||||||
f"{self.__class__.__name__}'s iterable must be an iterable "
|
|
||||||
"such as a sequence or a mapping"
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(iterable, Mapping):
|
|
||||||
iterable = tuple(iterable.items())
|
|
||||||
else:
|
|
||||||
iterable = tuple(enumerate(iterable))
|
|
||||||
|
|
||||||
try:
|
|
||||||
output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise EntryInvalidError(exc)
|
|
||||||
|
|
||||||
result = output_class()
|
|
||||||
|
|
||||||
self.__current_context = tuple()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for item in iterable:
|
|
||||||
self.__current_context = item
|
|
||||||
resolved_body = entry.tag_resolver(self.item_body, blueprint)
|
|
||||||
result = add_fn(result, resolved_body)
|
|
||||||
if not isinstance(result, output_class):
|
|
||||||
raise EntryInvalidError(
|
|
||||||
f"Invalid {self.__class__.__name__} item found: {resolved_body}"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self.__current_context = tuple()
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class EnumeratedItem(YAMLTag):
|
|
||||||
"""Get the current item value and index provided by an Enumerate tag context"""
|
|
||||||
|
|
||||||
depth: int
|
|
||||||
|
|
||||||
_SUPPORTED_CONTEXT_TAGS = (Enumerate,)
|
|
||||||
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.depth = int(node.value)
|
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
|
||||||
try:
|
|
||||||
context_tag: Enumerate = entry._get_tag_context(
|
|
||||||
depth=self.depth,
|
|
||||||
context_tag_type=EnumeratedItem._SUPPORTED_CONTEXT_TAGS,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
if self.depth == 0:
|
|
||||||
raise EntryInvalidError(
|
|
||||||
f"{self.__class__.__name__} tags are only usable "
|
|
||||||
f"inside an {Enumerate.__name__} tag"
|
|
||||||
)
|
|
||||||
|
|
||||||
raise EntryInvalidError(f"{self.__class__.__name__} tag: {exc}")
|
|
||||||
|
|
||||||
return context_tag.get_context(entry, blueprint)
|
|
||||||
|
|
||||||
|
|
||||||
class Index(EnumeratedItem):
|
|
||||||
"""Get the current item index provided by an Enumerate tag context"""
|
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
|
||||||
context = super().resolve(entry, blueprint)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return context[0]
|
|
||||||
except IndexError: # pragma: no cover
|
|
||||||
raise EntryInvalidError(f"Empty/invalid context: {context}")
|
|
||||||
|
|
||||||
|
|
||||||
class Value(EnumeratedItem):
|
|
||||||
"""Get the current item value provided by an Enumerate tag context"""
|
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
|
||||||
context = super().resolve(entry, blueprint)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return context[1]
|
|
||||||
except IndexError: # pragma: no cover
|
|
||||||
raise EntryInvalidError(f"Empty/invalid context: {context}")
|
|
||||||
|
|
||||||
|
|
||||||
class BlueprintDumper(SafeDumper):
|
class BlueprintDumper(SafeDumper):
|
||||||
"""Dump dataclasses to yaml"""
|
"""Dump dataclasses to yaml"""
|
||||||
|
|
||||||
@ -560,9 +394,6 @@ class BlueprintLoader(SafeLoader):
|
|||||||
self.add_constructor("!Condition", Condition)
|
self.add_constructor("!Condition", Condition)
|
||||||
self.add_constructor("!If", If)
|
self.add_constructor("!If", If)
|
||||||
self.add_constructor("!Env", Env)
|
self.add_constructor("!Env", Env)
|
||||||
self.add_constructor("!Enumerate", Enumerate)
|
|
||||||
self.add_constructor("!Value", Value)
|
|
||||||
self.add_constructor("!Index", Index)
|
|
||||||
|
|
||||||
|
|
||||||
class EntryInvalidError(SentryIgnoredException):
|
class EntryInvalidError(SentryIgnoredException):
|
||||||
|
@ -7,7 +7,6 @@ from dacite.config import Config
|
|||||||
from dacite.core import from_dict
|
from dacite.core import from_dict
|
||||||
from dacite.exceptions import DaciteError
|
from dacite.exceptions import DaciteError
|
||||||
from deepmerge import always_merger
|
from deepmerge import always_merger
|
||||||
from django.core.exceptions import FieldError
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.db.models.query_utils import Q
|
from django.db.models.query_utils import Q
|
||||||
@ -182,10 +181,7 @@ class Importer:
|
|||||||
if not query:
|
if not query:
|
||||||
raise EntryInvalidError("No or invalid identifiers")
|
raise EntryInvalidError("No or invalid identifiers")
|
||||||
|
|
||||||
try:
|
existing_models = model.objects.filter(query)
|
||||||
existing_models = model.objects.filter(query)
|
|
||||||
except FieldError as exc:
|
|
||||||
raise EntryInvalidError(f"Invalid identifier field: {exc}") from exc
|
|
||||||
|
|
||||||
serializer_kwargs = {}
|
serializer_kwargs = {}
|
||||||
model_instance = existing_models.first()
|
model_instance = existing_models.first()
|
||||||
@ -235,7 +231,8 @@ class Importer:
|
|||||||
raise IntegrityError
|
raise IntegrityError
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
return False
|
return False
|
||||||
self.logger.debug("Committing changes")
|
else:
|
||||||
|
self.logger.debug("Committing changes")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _apply_models(self) -> bool:
|
def _apply_models(self) -> bool:
|
||||||
|
@ -3,4 +3,3 @@
|
|||||||
LABEL_AUTHENTIK_SYSTEM = "blueprints.goauthentik.io/system"
|
LABEL_AUTHENTIK_SYSTEM = "blueprints.goauthentik.io/system"
|
||||||
LABEL_AUTHENTIK_INSTANTIATE = "blueprints.goauthentik.io/instantiate"
|
LABEL_AUTHENTIK_INSTANTIATE = "blueprints.goauthentik.io/instantiate"
|
||||||
LABEL_AUTHENTIK_GENERATED = "blueprints.goauthentik.io/generated"
|
LABEL_AUTHENTIK_GENERATED = "blueprints.goauthentik.io/generated"
|
||||||
LABEL_AUTHENTIK_DESCRIPTION = "blueprints.goauthentik.io/description"
|
|
||||||
|
@ -56,4 +56,5 @@ class MetaApplyBlueprint(BaseMetaModel):
|
|||||||
return ApplyBlueprintMetaSerializer
|
return ApplyBlueprintMetaSerializer
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
|
@ -14,6 +14,7 @@ class BaseMetaModel(Model):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,98 +0,0 @@
|
|||||||
"""OCI Client"""
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import ParseResult, urlparse
|
|
||||||
|
|
||||||
from opencontainers.distribution.reggie import (
|
|
||||||
NewClient,
|
|
||||||
WithDebug,
|
|
||||||
WithDefaultName,
|
|
||||||
WithDigest,
|
|
||||||
WithReference,
|
|
||||||
WithUserAgent,
|
|
||||||
WithUsernamePassword,
|
|
||||||
)
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
from structlog import get_logger
|
|
||||||
from structlog.stdlib import BoundLogger
|
|
||||||
|
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
|
||||||
from authentik.lib.utils.http import authentik_user_agent
|
|
||||||
|
|
||||||
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
|
|
||||||
|
|
||||||
|
|
||||||
class OCIException(SentryIgnoredException):
|
|
||||||
"""OCI-related errors"""
|
|
||||||
|
|
||||||
|
|
||||||
class BlueprintOCIClient:
|
|
||||||
"""Blueprint OCI Client"""
|
|
||||||
|
|
||||||
url: ParseResult
|
|
||||||
sanitized_url: str
|
|
||||||
logger: BoundLogger
|
|
||||||
ref: str
|
|
||||||
client: NewClient
|
|
||||||
|
|
||||||
def __init__(self, url: str) -> None:
|
|
||||||
self._parse_url(url)
|
|
||||||
self.logger = get_logger().bind(url=self.sanitized_url)
|
|
||||||
|
|
||||||
self.ref = "latest"
|
|
||||||
path = self.url.path[1:]
|
|
||||||
if ":" in self.url.path:
|
|
||||||
path, _, self.ref = path.partition(":")
|
|
||||||
self.client = NewClient(
|
|
||||||
f"https://{self.url.hostname}",
|
|
||||||
WithUserAgent(authentik_user_agent()),
|
|
||||||
WithUsernamePassword(self.url.username, self.url.password),
|
|
||||||
WithDefaultName(path),
|
|
||||||
WithDebug(True),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _parse_url(self, url: str):
|
|
||||||
self.url = urlparse(url)
|
|
||||||
netloc = self.url.netloc
|
|
||||||
if "@" in netloc:
|
|
||||||
netloc = netloc[netloc.index("@") + 1 :]
|
|
||||||
self.sanitized_url = self.url._replace(netloc=netloc).geturl()
|
|
||||||
|
|
||||||
def fetch_manifests(self) -> dict[str, Any]:
|
|
||||||
"""Fetch manifests for ref"""
|
|
||||||
self.logger.info("Fetching OCI manifests for blueprint")
|
|
||||||
manifest_request = self.client.NewRequest(
|
|
||||||
"GET",
|
|
||||||
"/v2/<name>/manifests/<reference>",
|
|
||||||
WithReference(self.ref),
|
|
||||||
).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
|
|
||||||
try:
|
|
||||||
manifest_response = self.client.Do(manifest_request)
|
|
||||||
manifest_response.raise_for_status()
|
|
||||||
except RequestException as exc:
|
|
||||||
raise OCIException(exc) from exc
|
|
||||||
manifest = manifest_response.json()
|
|
||||||
if "errors" in manifest:
|
|
||||||
raise OCIException(manifest["errors"])
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
def fetch_blobs(self, manifest: dict[str, Any]):
|
|
||||||
"""Fetch blob based on manifest info"""
|
|
||||||
blob = None
|
|
||||||
for layer in manifest.get("layers", []):
|
|
||||||
if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
|
|
||||||
blob = layer.get("digest")
|
|
||||||
self.logger.debug("Found layer with matching media type", blob=blob)
|
|
||||||
if not blob:
|
|
||||||
raise OCIException("Blob not found")
|
|
||||||
|
|
||||||
blob_request = self.client.NewRequest(
|
|
||||||
"GET",
|
|
||||||
"/v2/<name>/blobs/<digest>",
|
|
||||||
WithDigest(blob),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
blob_response = self.client.Do(blob_request)
|
|
||||||
blob_response.raise_for_status()
|
|
||||||
return blob_response.text
|
|
||||||
except RequestException as exc:
|
|
||||||
raise OCIException(exc) from exc
|
|
@ -219,14 +219,3 @@ def apply_blueprint(self: MonitoredTask, instance_pk: str):
|
|||||||
finally:
|
finally:
|
||||||
if instance:
|
if instance:
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task()
|
|
||||||
def clear_failed_blueprints():
|
|
||||||
"""Remove blueprints which couldn't be fetched"""
|
|
||||||
# Exclude OCI blueprints as those might be temporarily unavailable
|
|
||||||
for blueprint in BlueprintInstance.objects.exclude(path__startswith="oci://"):
|
|
||||||
try:
|
|
||||||
blueprint.retrieve()
|
|
||||||
except BlueprintRetrievalFailed:
|
|
||||||
blueprint.delete()
|
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
"""Application API Views"""
|
"""Application API Views"""
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.db.models.functions import ExtractHour
|
|
||||||
from django.http.response import HttpResponseBadRequest
|
from django.http.response import HttpResponseBadRequest
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
@ -63,6 +61,7 @@ class ApplicationSerializer(ModelSerializer):
|
|||||||
return app.get_launch_url(user)
|
return app.get_launch_url(user)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Application
|
model = Application
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -226,6 +225,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
parser_classes=(MultiPartParser,),
|
parser_classes=(MultiPartParser,),
|
||||||
)
|
)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def set_icon(self, request: Request, slug: str):
|
def set_icon(self, request: Request, slug: str):
|
||||||
"""Set application icon"""
|
"""Set application icon"""
|
||||||
app: Application = self.get_object()
|
app: Application = self.get_object()
|
||||||
@ -245,6 +245,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
)
|
)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def set_icon_url(self, request: Request, slug: str):
|
def set_icon_url(self, request: Request, slug: str):
|
||||||
"""Set application icon (as URL)"""
|
"""Set application icon (as URL)"""
|
||||||
app: Application = self.get_object()
|
app: Application = self.get_object()
|
||||||
@ -253,14 +254,15 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
||||||
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def metrics(self, request: Request, slug: str):
|
def metrics(self, request: Request, slug: str):
|
||||||
"""Metrics for application logins"""
|
"""Metrics for application logins"""
|
||||||
app = self.get_object()
|
app = self.get_object()
|
||||||
return Response(
|
return Response(
|
||||||
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||||
|
.filter(
|
||||||
action=EventAction.AUTHORIZE_APPLICATION,
|
action=EventAction.AUTHORIZE_APPLICATION,
|
||||||
context__authorized_application__pk=app.pk.hex,
|
context__authorized_application__pk=app.pk.hex,
|
||||||
)
|
)
|
||||||
# 3 data points per day, so 8 hour spans
|
.get_events_per_hour()
|
||||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
|
||||||
)
|
)
|
||||||
|
@ -74,6 +74,7 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
|||||||
return GEOIP_READER.city_dict(instance.last_ip)
|
return GEOIP_READER.city_dict(instance.last_ip)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = AuthenticatedSession
|
model = AuthenticatedSession
|
||||||
fields = [
|
fields = [
|
||||||
"uuid",
|
"uuid",
|
||||||
|
@ -29,6 +29,7 @@ class GroupMemberSerializer(ModelSerializer):
|
|||||||
uid = CharField(read_only=True)
|
uid = CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = User
|
model = User
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -55,6 +56,7 @@ class GroupSerializer(ModelSerializer):
|
|||||||
num_pk = IntegerField(read_only=True)
|
num_pk = IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Group
|
model = Group
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -94,6 +96,7 @@ class GroupFilter(FilterSet):
|
|||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def filter_attributes(self, queryset, name, value):
|
def filter_attributes(self, queryset, name, value):
|
||||||
"""Filter attributes by query args"""
|
"""Filter attributes by query args"""
|
||||||
try:
|
try:
|
||||||
@ -112,6 +115,7 @@ class GroupFilter(FilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Group
|
model = Group
|
||||||
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
|
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
|
||||||
|
|
||||||
@ -153,6 +157,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
||||||
|
# pylint: disable=unused-argument, invalid-name
|
||||||
def add_user(self, request: Request, pk: str) -> Response:
|
def add_user(self, request: Request, pk: str) -> Response:
|
||||||
"""Add user to group"""
|
"""Add user to group"""
|
||||||
group: Group = self.get_object()
|
group: Group = self.get_object()
|
||||||
@ -177,6 +182,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
||||||
|
# pylint: disable=unused-argument, invalid-name
|
||||||
def remove_user(self, request: Request, pk: str) -> Response:
|
def remove_user(self, request: Request, pk: str) -> Response:
|
||||||
"""Add user to group"""
|
"""Add user to group"""
|
||||||
group: Group = self.get_object()
|
group: Group = self.get_object()
|
||||||
|
@ -49,6 +49,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
|
|||||||
return expression
|
return expression
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = PropertyMapping
|
model = PropertyMapping
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -116,6 +117,7 @@ class PropertyMappingViewSet(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||||
|
# pylint: disable=unused-argument, invalid-name
|
||||||
def test(self, request: Request, pk: str) -> Response:
|
def test(self, request: Request, pk: str) -> Response:
|
||||||
"""Test Property Mapping"""
|
"""Test Property Mapping"""
|
||||||
mapping: PropertyMapping = self.get_object()
|
mapping: PropertyMapping = self.get_object()
|
||||||
|
@ -31,6 +31,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
|||||||
return obj.component
|
return obj.component
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Provider
|
model = Provider
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
|
@ -46,6 +46,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
|||||||
return obj.component
|
return obj.component
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Source
|
model = Source
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -101,6 +102,7 @@ class SourceViewSet(
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
parser_classes=(MultiPartParser,),
|
parser_classes=(MultiPartParser,),
|
||||||
)
|
)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def set_icon(self, request: Request, slug: str):
|
def set_icon(self, request: Request, slug: str):
|
||||||
"""Set source icon"""
|
"""Set source icon"""
|
||||||
source: Source = self.get_object()
|
source: Source = self.get_object()
|
||||||
@ -120,6 +122,7 @@ class SourceViewSet(
|
|||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
)
|
)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def set_icon_url(self, request: Request, slug: str):
|
def set_icon_url(self, request: Request, slug: str):
|
||||||
"""Set source icon (as URL)"""
|
"""Set source icon (as URL)"""
|
||||||
source: Source = self.get_object()
|
source: Source = self.get_object()
|
||||||
|
@ -39,6 +39,7 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
|
|||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Token
|
model = Token
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -111,6 +112,7 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def view_key(self, request: Request, identifier: str) -> Response:
|
def view_key(self, request: Request, identifier: str) -> Response:
|
||||||
"""Return token key and log access"""
|
"""Return token key and log access"""
|
||||||
token: Token = self.get_object()
|
token: Token = self.get_object()
|
||||||
@ -132,11 +134,11 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def set_key(self, request: Request, identifier: str) -> Response:
|
def set_key(self, request: Request, identifier: str) -> Response:
|
||||||
"""Set token key. Action is logged as event. `authentik_core.set_token_key` permission
|
"""Return token key and log access"""
|
||||||
is required."""
|
|
||||||
token: Token = self.get_object()
|
token: Token = self.get_object()
|
||||||
key = request.data.get("key")
|
key = request.POST.get("key")
|
||||||
if not key:
|
if not key:
|
||||||
return Response(status=400)
|
return Response(status=400)
|
||||||
token.key = key
|
token.key = key
|
||||||
|
@ -53,7 +53,7 @@ class UsedByMixin:
|
|||||||
responses={200: UsedBySerializer(many=True)},
|
responses={200: UsedBySerializer(many=True)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=too-many-locals
|
# pylint: disable=invalid-name, unused-argument, too-many-locals
|
||||||
def used_by(self, request: Request, *args, **kwargs) -> Response:
|
def used_by(self, request: Request, *args, **kwargs) -> Response:
|
||||||
"""Get a list of all objects that use this object"""
|
"""Get a list of all objects that use this object"""
|
||||||
# pyright: reportGeneralTypeIssues=false
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
@ -4,9 +4,6 @@ from json import loads
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.db.models.functions import ExtractHour
|
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
@ -43,7 +40,6 @@ from rest_framework.serializers import (
|
|||||||
PrimaryKeyRelatedField,
|
PrimaryKeyRelatedField,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
from rest_framework.validators import UniqueValidator
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
@ -60,7 +56,6 @@ from authentik.core.models import (
|
|||||||
USER_ATTRIBUTE_SA,
|
USER_ATTRIBUTE_SA,
|
||||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||||
USER_PATH_SERVICE_ACCOUNT,
|
USER_PATH_SERVICE_ACCOUNT,
|
||||||
AuthenticatedSession,
|
|
||||||
Group,
|
Group,
|
||||||
Token,
|
Token,
|
||||||
TokenIntents,
|
TokenIntents,
|
||||||
@ -85,6 +80,7 @@ class UserGroupSerializer(ModelSerializer):
|
|||||||
parent_name = CharField(source="parent.name", read_only=True)
|
parent_name = CharField(source="parent.name", read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Group
|
model = Group
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -108,7 +104,7 @@ class UserSerializer(ModelSerializer):
|
|||||||
)
|
)
|
||||||
groups_obj = ListSerializer(child=UserGroupSerializer(), read_only=True, source="ak_groups")
|
groups_obj = ListSerializer(child=UserGroupSerializer(), read_only=True, source="ak_groups")
|
||||||
uid = CharField(read_only=True)
|
uid = CharField(read_only=True)
|
||||||
username = CharField(max_length=150, validators=[UniqueValidator(queryset=User.objects.all())])
|
username = CharField(max_length=150)
|
||||||
|
|
||||||
def validate_path(self, path: str) -> str:
|
def validate_path(self, path: str) -> str:
|
||||||
"""Validate path"""
|
"""Validate path"""
|
||||||
@ -120,6 +116,7 @@ class UserSerializer(ModelSerializer):
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = User
|
model = User
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -171,6 +168,7 @@ class UserSelfSerializer(ModelSerializer):
|
|||||||
return user.group_attributes(self._context["request"]).get("settings", {})
|
return user.group_attributes(self._context["request"]).get("settings", {})
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = User
|
model = User
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -201,44 +199,38 @@ class SessionUserSerializer(PassiveSerializer):
|
|||||||
class UserMetricsSerializer(PassiveSerializer):
|
class UserMetricsSerializer(PassiveSerializer):
|
||||||
"""User Metrics"""
|
"""User Metrics"""
|
||||||
|
|
||||||
logins = SerializerMethodField()
|
logins_per_1h = SerializerMethodField()
|
||||||
logins_failed = SerializerMethodField()
|
logins_failed_per_1h = SerializerMethodField()
|
||||||
authorizations = SerializerMethodField()
|
authorizations_per_1h = SerializerMethodField()
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins(self, _):
|
def get_logins_per_1h(self, _):
|
||||||
"""Get successful logins per 8 hours for the last 7 days"""
|
"""Get successful logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
action=EventAction.LOGIN, user__pk=user.pk
|
.filter(action=EventAction.LOGIN, user__pk=user.pk)
|
||||||
)
|
.get_events_per_hour()
|
||||||
# 3 data points per day, so 8 hour spans
|
|
||||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed(self, _):
|
def get_logins_failed_per_1h(self, _):
|
||||||
"""Get failed logins per 8 hours for the last 7 days"""
|
"""Get failed logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
action=EventAction.LOGIN_FAILED, context__username=user.username
|
.filter(action=EventAction.LOGIN_FAILED, context__username=user.username)
|
||||||
)
|
.get_events_per_hour()
|
||||||
# 3 data points per day, so 8 hour spans
|
|
||||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_authorizations(self, _):
|
def get_authorizations_per_1h(self, _):
|
||||||
"""Get failed logins per 8 hours for the last 7 days"""
|
"""Get failed logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
|
.filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
|
||||||
)
|
.get_events_per_hour()
|
||||||
# 3 data points per day, so 8 hour spans
|
|
||||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -270,6 +262,7 @@ class UsersFilter(FilterSet):
|
|||||||
queryset=Group.objects.all(),
|
queryset=Group.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def filter_attributes(self, queryset, name, value):
|
def filter_attributes(self, queryset, name, value):
|
||||||
"""Filter attributes by query args"""
|
"""Filter attributes by query args"""
|
||||||
try:
|
try:
|
||||||
@ -400,12 +393,13 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
)
|
)
|
||||||
response["token"] = token.key
|
response["token"] = token.key
|
||||||
return Response(response)
|
return Response(response)
|
||||||
except IntegrityError as exc:
|
except (IntegrityError) as exc:
|
||||||
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
||||||
|
|
||||||
@extend_schema(responses={200: SessionUserSerializer(many=False)})
|
@extend_schema(responses={200: SessionUserSerializer(many=False)})
|
||||||
@action(url_path="me", url_name="me", detail=False, pagination_class=None, filter_backends=[])
|
@action(detail=False, pagination_class=None, filter_backends=[])
|
||||||
def user_me(self, request: Request) -> Response:
|
# pylint: disable=invalid-name
|
||||||
|
def me(self, request: Request) -> Response:
|
||||||
"""Get information about current user"""
|
"""Get information about current user"""
|
||||||
context = {"request": request}
|
context = {"request": request}
|
||||||
serializer = SessionUserSerializer(
|
serializer = SessionUserSerializer(
|
||||||
@ -433,6 +427,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"])
|
@action(detail=True, methods=["POST"])
|
||||||
|
# pylint: disable=invalid-name, unused-argument
|
||||||
def set_password(self, request: Request, pk: int) -> Response:
|
def set_password(self, request: Request, pk: int) -> Response:
|
||||||
"""Set password for user"""
|
"""Set password for user"""
|
||||||
user: User = self.get_object()
|
user: User = self.get_object()
|
||||||
@ -450,6 +445,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
||||||
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
|
# pylint: disable=invalid-name, unused-argument
|
||||||
def metrics(self, request: Request, pk: int) -> Response:
|
def metrics(self, request: Request, pk: int) -> Response:
|
||||||
"""User metrics per 1h"""
|
"""User metrics per 1h"""
|
||||||
user: User = self.get_object()
|
user: User = self.get_object()
|
||||||
@ -465,6 +461,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
|
# pylint: disable=invalid-name, unused-argument
|
||||||
def recovery(self, request: Request, pk: int) -> Response:
|
def recovery(self, request: Request, pk: int) -> Response:
|
||||||
"""Create a temporary link that a user can use to recover their accounts"""
|
"""Create a temporary link that a user can use to recover their accounts"""
|
||||||
link, _ = self._create_recovery_link()
|
link, _ = self._create_recovery_link()
|
||||||
@ -489,6 +486,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
|
# pylint: disable=invalid-name, unused-argument
|
||||||
def recovery_email(self, request: Request, pk: int) -> Response:
|
def recovery_email(self, request: Request, pk: int) -> Response:
|
||||||
"""Create a temporary link that a user can use to recover their accounts"""
|
"""Create a temporary link that a user can use to recover their accounts"""
|
||||||
for_user: User = self.get_object()
|
for_user: User = self.get_object()
|
||||||
@ -562,14 +560,3 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def partial_update(self, request: Request, *args, **kwargs) -> Response:
|
|
||||||
response = super().partial_update(request, *args, **kwargs)
|
|
||||||
instance: User = self.get_object()
|
|
||||||
if not instance.is_active:
|
|
||||||
sessions = AuthenticatedSession.objects.filter(user=instance)
|
|
||||||
session_ids = sessions.values_list("session_key", flat=True)
|
|
||||||
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
|
|
||||||
sessions.delete()
|
|
||||||
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
|
||||||
return response
|
|
||||||
|
@ -49,6 +49,7 @@ class Command(BaseCommand):
|
|||||||
return namespace
|
return namespace
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def post_save_handler(sender, instance: Model, created: bool, **_):
|
def post_save_handler(sender, instance: Model, created: bool, **_):
|
||||||
"""Signal handler for all object's post_save"""
|
"""Signal handler for all object's post_save"""
|
||||||
if not should_log_model(instance):
|
if not should_log_model(instance):
|
||||||
@ -64,6 +65,7 @@ class Command(BaseCommand):
|
|||||||
).save()
|
).save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def pre_delete_handler(sender, instance: Model, **_):
|
def pre_delete_handler(sender, instance: Model, **_):
|
||||||
"""Signal handler for all object's pre_delete"""
|
"""Signal handler for all object's pre_delete"""
|
||||||
if not should_log_model(instance): # pragma: no cover
|
if not should_log_model(instance): # pragma: no cover
|
||||||
|
@ -14,6 +14,7 @@ import authentik.core.models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@ -43,10 +44,7 @@ class Migration(migrations.Migration):
|
|||||||
"is_superuser",
|
"is_superuser",
|
||||||
models.BooleanField(
|
models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text=(
|
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||||
"Designates that this user has all permissions without explicitly"
|
|
||||||
" assigning them."
|
|
||||||
),
|
|
||||||
verbose_name="superuser status",
|
verbose_name="superuser status",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -54,9 +52,7 @@ class Migration(migrations.Migration):
|
|||||||
"username",
|
"username",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
error_messages={"unique": "A user with that username already exists."},
|
error_messages={"unique": "A user with that username already exists."},
|
||||||
help_text=(
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
"Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
|
|
||||||
),
|
|
||||||
max_length=150,
|
max_length=150,
|
||||||
unique=True,
|
unique=True,
|
||||||
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
|
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
|
||||||
@ -87,10 +83,7 @@ class Migration(migrations.Migration):
|
|||||||
"is_active",
|
"is_active",
|
||||||
models.BooleanField(
|
models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
help_text=(
|
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||||
"Designates whether this user should be treated as active. Unselect"
|
|
||||||
" this instead of deleting accounts."
|
|
||||||
),
|
|
||||||
verbose_name="active",
|
verbose_name="active",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -51,6 +51,7 @@ def create_default_admin_group(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
replaces = [
|
replaces = [
|
||||||
("authentik_core", "0002_auto_20200523_1133"),
|
("authentik_core", "0002_auto_20200523_1133"),
|
||||||
("authentik_core", "0003_default_user"),
|
("authentik_core", "0003_default_user"),
|
||||||
@ -171,10 +172,7 @@ class Migration(migrations.Migration):
|
|||||||
name="groups",
|
name="groups",
|
||||||
field=models.ManyToManyField(
|
field=models.ManyToManyField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=(
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
"The groups this user belongs to. A user will get all permissions granted to"
|
|
||||||
" each of their groups."
|
|
||||||
),
|
|
||||||
related_name="user_set",
|
related_name="user_set",
|
||||||
related_query_name="user",
|
related_query_name="user",
|
||||||
to="auth.Group",
|
to="auth.Group",
|
||||||
|
@ -17,6 +17,7 @@ def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
replaces = [
|
replaces = [
|
||||||
("authentik_core", "0012_auto_20201003_1737"),
|
("authentik_core", "0012_auto_20201003_1737"),
|
||||||
("authentik_core", "0013_auto_20201003_2132"),
|
("authentik_core", "0013_auto_20201003_2132"),
|
||||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_core", "0016_auto_20201202_2234"),
|
("authentik_core", "0016_auto_20201202_2234"),
|
||||||
]
|
]
|
||||||
@ -14,12 +15,7 @@ class Migration(migrations.Migration):
|
|||||||
name="managed",
|
name="managed",
|
||||||
field=models.TextField(
|
field=models.TextField(
|
||||||
default=None,
|
default=None,
|
||||||
help_text=(
|
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||||
"Objects which are managed by authentik. These objects are created and updated"
|
|
||||||
" automatically. This is flag only indicates that an object can be overwritten"
|
|
||||||
" by migrations. You can still modify the objects via the API, but expect"
|
|
||||||
" changes to be overwritten in a later update."
|
|
||||||
),
|
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Managed by authentik",
|
verbose_name="Managed by authentik",
|
||||||
unique=True,
|
unique=True,
|
||||||
@ -30,12 +26,7 @@ class Migration(migrations.Migration):
|
|||||||
name="managed",
|
name="managed",
|
||||||
field=models.TextField(
|
field=models.TextField(
|
||||||
default=None,
|
default=None,
|
||||||
help_text=(
|
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||||
"Objects which are managed by authentik. These objects are created and updated"
|
|
||||||
" automatically. This is flag only indicates that an object can be overwritten"
|
|
||||||
" by migrations. You can still modify the objects via the API, but expect"
|
|
||||||
" changes to be overwritten in a later update."
|
|
||||||
),
|
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Managed by authentik",
|
verbose_name="Managed by authentik",
|
||||||
unique=True,
|
unique=True,
|
||||||
|
@ -63,6 +63,7 @@ def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
replaces = [
|
replaces = [
|
||||||
("authentik_core", "0018_auto_20210330_1345"),
|
("authentik_core", "0018_auto_20210330_1345"),
|
||||||
("authentik_core", "0019_source_managed"),
|
("authentik_core", "0019_source_managed"),
|
||||||
@ -95,12 +96,7 @@ class Migration(migrations.Migration):
|
|||||||
name="managed",
|
name="managed",
|
||||||
field=models.TextField(
|
field=models.TextField(
|
||||||
default=None,
|
default=None,
|
||||||
help_text=(
|
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||||
"Objects which are managed by authentik. These objects are created and updated"
|
|
||||||
" automatically. This is flag only indicates that an object can be overwritten"
|
|
||||||
" by migrations. You can still modify the objects via the API, but expect"
|
|
||||||
" changes to be overwritten in a later update."
|
|
||||||
),
|
|
||||||
null=True,
|
null=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="Managed by authentik",
|
verbose_name="Managed by authentik",
|
||||||
@ -114,38 +110,23 @@ class Migration(migrations.Migration):
|
|||||||
("identifier", "Use the source-specific identifier"),
|
("identifier", "Use the source-specific identifier"),
|
||||||
(
|
(
|
||||||
"email_link",
|
"email_link",
|
||||||
(
|
"Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.",
|
||||||
"Link to a user with identical email address. Can have security"
|
|
||||||
" implications when a source doesn't validate email addresses."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"email_deny",
|
"email_deny",
|
||||||
(
|
"Use the user's email address, but deny enrollment when the email address already exists.",
|
||||||
"Use the user's email address, but deny enrollment when the email"
|
|
||||||
" address already exists."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"username_link",
|
"username_link",
|
||||||
(
|
"Link to a user with identical username. Can have security implications when a username is used with another source.",
|
||||||
"Link to a user with identical username. Can have security implications"
|
|
||||||
" when a username is used with another source."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"username_deny",
|
"username_deny",
|
||||||
(
|
"Use the user's username, but deny enrollment when the username already exists.",
|
||||||
"Use the user's username, but deny enrollment when the username already"
|
|
||||||
" exists."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
default="identifier",
|
default="identifier",
|
||||||
help_text=(
|
help_text="How the source determines if an existing user should be authenticated or a new user enrolled.",
|
||||||
"How the source determines if an existing user should be authenticated or a new"
|
|
||||||
" user enrolled."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_core", "0018_auto_20210330_1345_squashed_0028_alter_token_intent"),
|
("authentik_core", "0018_auto_20210330_1345_squashed_0028_alter_token_intent"),
|
||||||
]
|
]
|
||||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_core", "0019_application_group"),
|
("authentik_core", "0019_application_group"),
|
||||||
]
|
]
|
||||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_core", "0020_application_open_in_new_tab"),
|
("authentik_core", "0020_application_open_in_new_tab"),
|
||||||
]
|
]
|
||||||
|
@ -5,6 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_core", "0021_source_user_path_user_path"),
|
("authentik_core", "0021_source_user_path_user_path"),
|
||||||
]
|
]
|
||||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_core", "0022_alter_group_parent"),
|
("authentik_core", "0022_alter_group_parent"),
|
||||||
]
|
]
|
||||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_core", "0023_source_authentik_c_slug_ccb2e5_idx_and_more"),
|
("authentik_core", "0023_source_authentik_c_slug_ccb2e5_idx_and_more"),
|
||||||
]
|
]
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
"""authentik core models"""
|
"""authentik core models"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from hashlib import sha256
|
from hashlib import md5, sha256
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from deepmerge import always_merger
|
from deepmerge import always_merger
|
||||||
@ -12,7 +13,9 @@ from django.contrib.auth.models import UserManager as DjangoUserManager
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q, QuerySet, options
|
from django.db.models import Q, QuerySet, options
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.templatetags.static import static
|
||||||
from django.utils.functional import SimpleLazyObject, cached_property
|
from django.utils.functional import SimpleLazyObject, cached_property
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from guardian.mixins import GuardianUserMixin
|
from guardian.mixins import GuardianUserMixin
|
||||||
@ -24,8 +27,7 @@ from authentik.blueprints.models import ManagedModel
|
|||||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||||
from authentik.core.signals import password_changed
|
from authentik.core.signals import password_changed
|
||||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||||
from authentik.lib.avatars import get_avatar
|
from authentik.lib.config import CONFIG, get_path_from_dict
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel
|
from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel
|
||||||
from authentik.lib.utils.http import get_client_ip
|
from authentik.lib.utils.http import get_client_ip
|
||||||
@ -47,6 +49,9 @@ USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
|||||||
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
|
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
|
||||||
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
|
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
|
||||||
|
|
||||||
|
GRAVATAR_URL = "https://secure.gravatar.com"
|
||||||
|
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
|
||||||
|
|
||||||
|
|
||||||
options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",)
|
options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",)
|
||||||
|
|
||||||
@ -124,6 +129,7 @@ class Group(SerializerModel):
|
|||||||
return f"Group {self.name}"
|
return f"Group {self.name}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
unique_together = (
|
unique_together = (
|
||||||
(
|
(
|
||||||
"name",
|
"name",
|
||||||
@ -228,9 +234,28 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
|
|||||||
@property
|
@property
|
||||||
def avatar(self) -> str:
|
def avatar(self) -> str:
|
||||||
"""Get avatar, depending on authentik.avatar setting"""
|
"""Get avatar, depending on authentik.avatar setting"""
|
||||||
return get_avatar(self)
|
mode: str = CONFIG.y("avatars", "none")
|
||||||
|
if mode == "none":
|
||||||
|
return DEFAULT_AVATAR
|
||||||
|
if mode.startswith("attributes."):
|
||||||
|
return get_path_from_dict(self.attributes, mode[11:], default=DEFAULT_AVATAR)
|
||||||
|
# gravatar uses md5 for their URLs, so md5 can't be avoided
|
||||||
|
mail_hash = md5(self.email.lower().encode("utf-8")).hexdigest() # nosec
|
||||||
|
if mode == "gravatar":
|
||||||
|
parameters = [
|
||||||
|
("s", "158"),
|
||||||
|
("r", "g"),
|
||||||
|
]
|
||||||
|
gravatar_url = f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
|
||||||
|
return escape(gravatar_url)
|
||||||
|
return mode % {
|
||||||
|
"username": self.username,
|
||||||
|
"mail_hash": mail_hash,
|
||||||
|
"upn": self.attributes.get("upn", ""),
|
||||||
|
}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
permissions = (
|
permissions = (
|
||||||
("reset_user_password", "Reset Password"),
|
("reset_user_password", "Reset Password"),
|
||||||
("impersonate", "Can impersonate other users"),
|
("impersonate", "Can impersonate other users"),
|
||||||
@ -357,6 +382,7 @@ class Application(SerializerModel, PolicyBindingModel):
|
|||||||
return str(self.name)
|
return str(self.name)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Application")
|
verbose_name = _("Application")
|
||||||
verbose_name_plural = _("Applications")
|
verbose_name_plural = _("Applications")
|
||||||
|
|
||||||
@ -366,15 +392,19 @@ class SourceUserMatchingModes(models.TextChoices):
|
|||||||
|
|
||||||
IDENTIFIER = "identifier", _("Use the source-specific identifier")
|
IDENTIFIER = "identifier", _("Use the source-specific identifier")
|
||||||
EMAIL_LINK = "email_link", _(
|
EMAIL_LINK = "email_link", _(
|
||||||
"Link to a user with identical email address. Can have security implications "
|
(
|
||||||
"when a source doesn't validate email addresses."
|
"Link to a user with identical email address. Can have security implications "
|
||||||
|
"when a source doesn't validate email addresses."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
EMAIL_DENY = "email_deny", _(
|
EMAIL_DENY = "email_deny", _(
|
||||||
"Use the user's email address, but deny enrollment when the email address already exists."
|
"Use the user's email address, but deny enrollment when the email address already exists."
|
||||||
)
|
)
|
||||||
USERNAME_LINK = "username_link", _(
|
USERNAME_LINK = "username_link", _(
|
||||||
"Link to a user with identical username. Can have security implications "
|
(
|
||||||
"when a username is used with another source."
|
"Link to a user with identical username. Can have security implications "
|
||||||
|
"when a username is used with another source."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
USERNAME_DENY = "username_deny", _(
|
USERNAME_DENY = "username_deny", _(
|
||||||
"Use the user's username, but deny enrollment when the username already exists."
|
"Use the user's username, but deny enrollment when the username already exists."
|
||||||
@ -421,8 +451,10 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
choices=SourceUserMatchingModes.choices,
|
choices=SourceUserMatchingModes.choices,
|
||||||
default=SourceUserMatchingModes.IDENTIFIER,
|
default=SourceUserMatchingModes.IDENTIFIER,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"How the source determines if an existing user should be authenticated or "
|
(
|
||||||
"a new user enrolled."
|
"How the source determines if an existing user should be authenticated or "
|
||||||
|
"a new user enrolled."
|
||||||
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -468,6 +500,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
return str(self.name)
|
return str(self.name)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(
|
models.Index(
|
||||||
fields=[
|
fields=[
|
||||||
@ -496,6 +529,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
unique_together = (("user", "source"),)
|
unique_together = (("user", "source"),)
|
||||||
|
|
||||||
|
|
||||||
@ -528,6 +562,7 @@ class ExpiringModel(models.Model):
|
|||||||
return now() > self.expires
|
return now() > self.expires
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
@ -593,6 +628,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
|
|||||||
return description
|
return description
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Token")
|
verbose_name = _("Token")
|
||||||
verbose_name_plural = _("Tokens")
|
verbose_name_plural = _("Tokens")
|
||||||
indexes = [
|
indexes = [
|
||||||
@ -635,6 +671,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
|
|||||||
return f"Property Mapping {self.name}"
|
return f"Property Mapping {self.name}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Property Mapping")
|
verbose_name = _("Property Mapping")
|
||||||
verbose_name_plural = _("Property Mappings")
|
verbose_name_plural = _("Property Mappings")
|
||||||
|
|
||||||
@ -671,5 +708,6 @@ class AuthenticatedSession(ExpiringModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Authenticated Session")
|
verbose_name = _("Authenticated Session")
|
||||||
verbose_name_plural = _("Authenticated Sessions")
|
verbose_name_plural = _("Authenticated Sessions")
|
||||||
|
@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||||
"""Clear user's application cache upon application creation"""
|
"""Clear user's application cache upon application creation"""
|
||||||
from authentik.core.api.applications import user_app_cache_key
|
from authentik.core.api.applications import user_app_cache_key
|
||||||
@ -35,6 +36,7 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
||||||
"""Create an AuthenticatedSession from request"""
|
"""Create an AuthenticatedSession from request"""
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
@ -45,6 +47,7 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(user_logged_out)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
||||||
"""Delete AuthenticatedSession if it exists"""
|
"""Delete AuthenticatedSession if it exists"""
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
|
@ -48,6 +48,7 @@ class Action(Enum):
|
|||||||
class MessageStage(StageView):
|
class MessageStage(StageView):
|
||||||
"""Show a pre-configured message after the flow is done"""
|
"""Show a pre-configured message after the flow is done"""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Show a pre-configured message after the flow is done"""
|
"""Show a pre-configured message after the flow is done"""
|
||||||
message = getattr(self.executor.current_stage, "message", "")
|
message = getattr(self.executor.current_stage, "message", "")
|
||||||
@ -190,8 +191,11 @@ class SourceFlowManager:
|
|||||||
# Default case, assume deny
|
# Default case, assume deny
|
||||||
error = Exception(
|
error = Exception(
|
||||||
_(
|
_(
|
||||||
"Request to authenticate with %(source)s has been denied. Please authenticate "
|
(
|
||||||
"with the source you've previously signed up with." % {"source": self.source.name}
|
"Request to authenticate with %(source)s has been denied. Please authenticate "
|
||||||
|
"with the source you've previously signed up with."
|
||||||
|
)
|
||||||
|
% {"source": self.source.name}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return self.error_handler(error)
|
return self.error_handler(error)
|
||||||
@ -205,6 +209,7 @@ class SourceFlowManager:
|
|||||||
response.error_message = error.messages
|
response.error_message = error.messages
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||||
"""Hook to override stages which are appended to the flow"""
|
"""Hook to override stages which are appended to the flow"""
|
||||||
if not self.source.enrollment_flow:
|
if not self.source.enrollment_flow:
|
||||||
@ -259,6 +264,7 @@ class SourceFlowManager:
|
|||||||
flow_slug=flow.slug,
|
flow_slug=flow.slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def handle_auth(
|
def handle_auth(
|
||||||
self,
|
self,
|
||||||
connection: UserSourceConnection,
|
connection: UserSourceConnection,
|
||||||
|
@ -13,6 +13,7 @@ class PostUserEnrollmentStage(StageView):
|
|||||||
"""Dynamically injected stage which saves the Connection after
|
"""Dynamically injected stage which saves the Connection after
|
||||||
the user has been enrolled."""
|
the user has been enrolled."""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Stage used after the user has been enrolled"""
|
"""Stage used after the user has been enrolled"""
|
||||||
connection: UserSourceConnection = self.executor.plan.context[
|
connection: UserSourceConnection = self.executor.plan.context[
|
||||||
|
@ -43,12 +43,7 @@ def clean_expired_models(self: MonitoredTask):
|
|||||||
amount = 0
|
amount = 0
|
||||||
for session in AuthenticatedSession.objects.all():
|
for session in AuthenticatedSession.objects.all():
|
||||||
cache_key = f"{KEY_PREFIX}{session.session_key}"
|
cache_key = f"{KEY_PREFIX}{session.session_key}"
|
||||||
value = None
|
value = cache.get(cache_key)
|
||||||
try:
|
|
||||||
value = cache.get(cache_key)
|
|
||||||
# pylint: disable=broad-except
|
|
||||||
except Exception as exc:
|
|
||||||
LOGGER.debug("Failed to get session from cache", exc=exc)
|
|
||||||
if not value:
|
if not value:
|
||||||
session.delete()
|
session.delete()
|
||||||
amount += 1
|
amount += 1
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}">
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/dropdown.css' %}">
|
||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||||
|
@ -21,15 +21,9 @@ You've logged out of {{ application }}.
|
|||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">
|
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">{% trans 'Go back to overview' %}</a>
|
||||||
{% trans 'Go back to overview' %}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">
|
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">{% trans 'Log out of authentik' %}</a>
|
||||||
{% blocktrans with branding_title=tenant.branding_title %}
|
|
||||||
Log out of {{ branding_title }}
|
|
||||||
{% endblocktrans %}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{% if application.get_launch_url %}
|
{% if application.get_launch_url %}
|
||||||
<a href="{{ application.get_launch_url }}" class="pf-c-button pf-m-secondary">
|
<a href="{{ application.get_launch_url }}" class="pf-c-button pf-m-secondary">
|
||||||
|
@ -60,7 +60,7 @@
|
|||||||
<div class="ak-login-container">
|
<div class="ak-login-container">
|
||||||
<header class="pf-c-login__header">
|
<header class="pf-c-login__header">
|
||||||
<div class="pf-c-brand ak-brand">
|
<div class="pf-c-brand ak-brand">
|
||||||
<img src="{{ tenant.branding_logo }}" alt="authentik Logo" />
|
<img src="{{ tenant.branding_logo }}" alt="authentik icon" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{% block main_container %}
|
{% block main_container %}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""Test Source flow_manager"""
|
"""Test Source flow_manager"""
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test.client import RequestFactory
|
||||||
from guardian.utils import get_anonymous_user
|
from guardian.utils import get_anonymous_user
|
||||||
|
|
||||||
from authentik.core.models import SourceUserMatchingModes, User
|
from authentik.core.models import SourceUserMatchingModes, User
|
||||||
@ -21,6 +22,7 @@ class TestSourceFlowManager(TestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.source: OAuthSource = OAuthSource.objects.create(name="test")
|
self.source: OAuthSource = OAuthSource.objects.create(name="test")
|
||||||
|
self.factory = RequestFactory()
|
||||||
self.identifier = generate_id()
|
self.identifier = generate_id()
|
||||||
|
|
||||||
def test_unauthenticated_enroll(self):
|
def test_unauthenticated_enroll(self):
|
||||||
|
@ -7,7 +7,6 @@ from rest_framework.test import APITestCase
|
|||||||
|
|
||||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
|
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
from authentik.lib.generators import generate_id
|
|
||||||
|
|
||||||
|
|
||||||
class TestTokenAPI(APITestCase):
|
class TestTokenAPI(APITestCase):
|
||||||
@ -31,28 +30,6 @@ class TestTokenAPI(APITestCase):
|
|||||||
self.assertEqual(token.expiring, True)
|
self.assertEqual(token.expiring, True)
|
||||||
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
||||||
|
|
||||||
def test_token_set_key(self):
|
|
||||||
"""Test token creation endpoint"""
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("authentik_api:token-list"), {"identifier": "test-token"}
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 201)
|
|
||||||
token = Token.objects.get(identifier="test-token")
|
|
||||||
self.assertEqual(token.user, self.user)
|
|
||||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
|
||||||
self.assertEqual(token.expiring, True)
|
|
||||||
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
|
||||||
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
new_key = generate_id()
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("authentik_api:token-set-key", kwargs={"identifier": token.identifier}),
|
|
||||||
{"key": new_key},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 204)
|
|
||||||
token.refresh_from_db()
|
|
||||||
self.assertEqual(token.key, new_key)
|
|
||||||
|
|
||||||
def test_token_create_invalid(self):
|
def test_token_create_invalid(self):
|
||||||
"""Test token creation endpoint (invalid data)"""
|
"""Test token creation endpoint (invalid data)"""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@ -80,7 +57,7 @@ class TestTokenAPI(APITestCase):
|
|||||||
identifier="test", expiring=False, user=self.user
|
identifier="test", expiring=False, user=self.user
|
||||||
)
|
)
|
||||||
Token.objects.create(identifier="test-2", expiring=False, user=get_anonymous_user())
|
Token.objects.create(identifier="test-2", expiring=False, user=get_anonymous_user())
|
||||||
response = self.client.get(reverse("authentik_api:token-list"))
|
response = self.client.get(reverse(("authentik_api:token-list")))
|
||||||
body = loads(response.content)
|
body = loads(response.content)
|
||||||
self.assertEqual(len(body["results"]), 1)
|
self.assertEqual(len(body["results"]), 1)
|
||||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
||||||
@ -94,7 +71,7 @@ class TestTokenAPI(APITestCase):
|
|||||||
token_should_not: Token = Token.objects.create(
|
token_should_not: Token = Token.objects.create(
|
||||||
identifier="test-2", expiring=False, user=get_anonymous_user()
|
identifier="test-2", expiring=False, user=get_anonymous_user()
|
||||||
)
|
)
|
||||||
response = self.client.get(reverse("authentik_api:token-list"))
|
response = self.client.get(reverse(("authentik_api:token-list")))
|
||||||
body = loads(response.content)
|
body = loads(response.content)
|
||||||
self.assertEqual(len(body["results"]), 2)
|
self.assertEqual(len(body["results"]), 2)
|
||||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
"""Test Users API"""
|
"""Test Users API"""
|
||||||
|
from json import loads
|
||||||
|
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession, User
|
from authentik.core.models import User
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||||
from authentik.flows.models import FlowDesignation
|
from authentik.flows.models import FlowDesignation
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
@ -220,25 +220,40 @@ class TestUsersAPI(APITestCase):
|
|||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_session_delete(self):
|
@CONFIG.patch("avatars", "none")
|
||||||
"""Ensure sessions are deleted when a user is deactivated"""
|
def test_avatars_none(self):
|
||||||
user = create_test_admin_user()
|
"""Test avatars none"""
|
||||||
session_id = generate_id()
|
|
||||||
AuthenticatedSession.objects.create(
|
|
||||||
user=user,
|
|
||||||
session_key=session_id,
|
|
||||||
last_ip="",
|
|
||||||
)
|
|
||||||
cache.set(KEY_PREFIX + session_id, "foo")
|
|
||||||
|
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
response = self.client.patch(
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
reverse("authentik_api:user-detail", kwargs={"pk": user.pk}),
|
|
||||||
data={
|
|
||||||
"is_active": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content.decode())
|
||||||
|
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
|
||||||
|
|
||||||
self.assertIsNone(cache.get(KEY_PREFIX + session_id))
|
@CONFIG.patch("avatars", "gravatar")
|
||||||
self.assertFalse(AuthenticatedSession.objects.filter(session_key=session_id).exists())
|
def test_avatars_gravatar(self):
|
||||||
|
"""Test avatars gravatar"""
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content.decode())
|
||||||
|
self.assertIn("gravatar", body["user"]["avatar"])
|
||||||
|
|
||||||
|
@CONFIG.patch("avatars", "foo-%(username)s")
|
||||||
|
def test_avatars_custom(self):
|
||||||
|
"""Test avatars custom"""
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
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"foo-{self.admin.username}")
|
||||||
|
|
||||||
|
@CONFIG.patch("avatars", "attributes.foo.avatar")
|
||||||
|
def test_avatars_attributes(self):
|
||||||
|
"""Test avatars attributes"""
|
||||||
|
self.admin.attributes = {"foo": {"avatar": "bar"}}
|
||||||
|
self.admin.save()
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
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"], "bar")
|
||||||
|
@ -1,84 +0,0 @@
|
|||||||
"""Test Users Avatars"""
|
|
||||||
from json import loads
|
|
||||||
|
|
||||||
from django.urls.base import reverse
|
|
||||||
from requests_mock import Mocker
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from authentik.core.models import User
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
|
|
||||||
|
|
||||||
class TestUsersAvatars(APITestCase):
|
|
||||||
"""Test Users avatars"""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.admin = create_test_admin_user()
|
|
||||||
self.user = User.objects.create(username="test-user")
|
|
||||||
|
|
||||||
@CONFIG.patch("avatars", "none")
|
|
||||||
def test_avatars_none(self):
|
|
||||||
"""Test avatars none"""
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
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"], "/static/dist/assets/images/user_default.png")
|
|
||||||
|
|
||||||
@CONFIG.patch("avatars", "gravatar")
|
|
||||||
def test_avatars_gravatar(self):
|
|
||||||
"""Test avatars gravatar"""
|
|
||||||
self.admin.email = "static@t.goauthentik.io"
|
|
||||||
self.admin.save()
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
with Mocker() as mocker:
|
|
||||||
mocker.head(
|
|
||||||
(
|
|
||||||
"https://secure.gravatar.com/avatar/84730f9c1851d1ea03f1a"
|
|
||||||
"a9ed85bd1ea?size=158&rating=g&default=404"
|
|
||||||
),
|
|
||||||
text="foo",
|
|
||||||
)
|
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
body = loads(response.content.decode())
|
|
||||||
self.assertIn("gravatar", body["user"]["avatar"])
|
|
||||||
|
|
||||||
@CONFIG.patch("avatars", "initials")
|
|
||||||
def test_avatars_initials(self):
|
|
||||||
"""Test avatars initials"""
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
body = loads(response.content.decode())
|
|
||||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
|
||||||
|
|
||||||
@CONFIG.patch("avatars", "foo://%(username)s")
|
|
||||||
def test_avatars_custom(self):
|
|
||||||
"""Test avatars custom"""
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
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"foo://{self.admin.username}")
|
|
||||||
|
|
||||||
@CONFIG.patch("avatars", "attributes.foo.avatar")
|
|
||||||
def test_avatars_attributes(self):
|
|
||||||
"""Test avatars attributes"""
|
|
||||||
self.admin.attributes = {"foo": {"avatar": "bar"}}
|
|
||||||
self.admin.save()
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
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"], "bar")
|
|
||||||
|
|
||||||
@CONFIG.patch("avatars", "attributes.foo.avatar,initials")
|
|
||||||
def test_avatars_fallback(self):
|
|
||||||
"""Test fallback"""
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
body = loads(response.content.decode())
|
|
||||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
|
@ -47,11 +47,11 @@ def create_test_tenant() -> Tenant:
|
|||||||
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
||||||
"""Generate a certificate for testing"""
|
"""Generate a certificate for testing"""
|
||||||
builder = CertificateBuilder(
|
builder = CertificateBuilder(
|
||||||
name=f"{generate_id()}.self-signed.goauthentik.io",
|
|
||||||
use_ec_private_key=use_ec_private_key,
|
use_ec_private_key=use_ec_private_key,
|
||||||
)
|
)
|
||||||
|
builder.common_name = "goauthentik.io"
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"],
|
subject_alt_names=["goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
builder.common_name = generate_id()
|
builder.common_name = generate_id()
|
||||||
|
@ -143,6 +143,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = CertificateKeyPair
|
model = CertificateKeyPair
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -186,6 +187,7 @@ class CertificateKeyPairFilter(FilterSet):
|
|||||||
label="Only return certificate-key pairs with keys", method="filter_has_key"
|
label="Only return certificate-key pairs with keys", method="filter_has_key"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def filter_has_key(self, queryset, name, value): # pragma: no cover
|
def filter_has_key(self, queryset, name, value): # pragma: no cover
|
||||||
"""Only return certificate-key pairs with keys"""
|
"""Only return certificate-key pairs with keys"""
|
||||||
return queryset.exclude(key_data__exact="")
|
return queryset.exclude(key_data__exact="")
|
||||||
@ -234,11 +236,10 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
data = CertificateGenerationSerializer(data=request.data)
|
data = CertificateGenerationSerializer(data=request.data)
|
||||||
if not data.is_valid():
|
if not data.is_valid():
|
||||||
return Response(data.errors, status=400)
|
return Response(data.errors, status=400)
|
||||||
raw_san = data.validated_data.get("subject_alt_name", "")
|
builder = CertificateBuilder()
|
||||||
sans = raw_san.split(",") if raw_san != "" else []
|
builder.common_name = data.validated_data["common_name"]
|
||||||
builder = CertificateBuilder(data.validated_data["common_name"])
|
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=sans,
|
subject_alt_names=data.validated_data.get("subject_alt_name", "").split(","),
|
||||||
validity_days=int(data.validated_data["validity_days"]),
|
validity_days=int(data.validated_data["validity_days"]),
|
||||||
)
|
)
|
||||||
instance = builder.save()
|
instance = builder.save()
|
||||||
@ -256,6 +257,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
responses={200: CertificateDataSerializer(many=False)},
|
responses={200: CertificateDataSerializer(many=False)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
|
# pylint: disable=invalid-name, unused-argument
|
||||||
def view_certificate(self, request: Request, pk: str) -> Response:
|
def view_certificate(self, request: Request, pk: str) -> Response:
|
||||||
"""Return certificate-key pairs certificate and log access"""
|
"""Return certificate-key pairs certificate and log access"""
|
||||||
certificate: CertificateKeyPair = self.get_object()
|
certificate: CertificateKeyPair = self.get_object()
|
||||||
@ -286,6 +288,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
responses={200: CertificateDataSerializer(many=False)},
|
responses={200: CertificateDataSerializer(many=False)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
|
# pylint: disable=invalid-name, unused-argument
|
||||||
def view_private_key(self, request: Request, pk: str) -> Response:
|
def view_private_key(self, request: Request, pk: str) -> Response:
|
||||||
"""Return certificate-key pairs private key and log access"""
|
"""Return certificate-key pairs private key and log access"""
|
||||||
certificate: CertificateKeyPair = self.get_object()
|
certificate: CertificateKeyPair = self.get_object()
|
||||||
|
@ -27,16 +27,20 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
|||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
builder = CertificateBuilder("authentik Internal JWT Certificate")
|
builder = CertificateBuilder()
|
||||||
|
builder.common_name = "goauthentik.io"
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=["goauthentik.io"],
|
subject_alt_names=["goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
if not cert:
|
if not cert:
|
||||||
|
|
||||||
cert = CertificateKeyPair()
|
cert = CertificateKeyPair()
|
||||||
builder.cert = cert
|
cert.certificate_data = builder.certificate
|
||||||
builder.cert.managed = MANAGED_KEY
|
cert.key_data = builder.private_key
|
||||||
builder.save()
|
cert.name = "authentik Internal JWT Certificate"
|
||||||
|
cert.managed = MANAGED_KEY
|
||||||
|
cert.save()
|
||||||
|
|
||||||
def reconcile_managed_jwt_cert(self):
|
def reconcile_managed_jwt_cert(self):
|
||||||
"""Ensure managed JWT certificate"""
|
"""Ensure managed JWT certificate"""
|
||||||
@ -59,6 +63,10 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
|||||||
name = "authentik Self-signed Certificate"
|
name = "authentik Self-signed Certificate"
|
||||||
if CertificateKeyPair.objects.filter(name=name).exists():
|
if CertificateKeyPair.objects.filter(name=name).exists():
|
||||||
return
|
return
|
||||||
builder = CertificateBuilder(name)
|
builder = CertificateBuilder()
|
||||||
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
||||||
builder.save()
|
CertificateKeyPair.objects.create(
|
||||||
|
name="authentik Self-signed Certificate",
|
||||||
|
certificate_data=builder.certificate,
|
||||||
|
key_data=builder.private_key,
|
||||||
|
)
|
||||||
|
@ -21,13 +21,13 @@ class CertificateBuilder:
|
|||||||
|
|
||||||
_use_ec_private_key: bool
|
_use_ec_private_key: bool
|
||||||
|
|
||||||
def __init__(self, name: str, use_ec_private_key=False):
|
def __init__(self, use_ec_private_key=False):
|
||||||
self._use_ec_private_key = use_ec_private_key
|
self._use_ec_private_key = use_ec_private_key
|
||||||
self.__public_key = None
|
self.__public_key = None
|
||||||
self.__private_key = None
|
self.__private_key = None
|
||||||
self.__builder = None
|
self.__builder = None
|
||||||
self.__certificate = None
|
self.__certificate = None
|
||||||
self.common_name = name
|
self.common_name = "authentik Self-signed Certificate"
|
||||||
self.cert = CertificateKeyPair()
|
self.cert = CertificateKeyPair()
|
||||||
|
|
||||||
def save(self) -> CertificateKeyPair:
|
def save(self) -> CertificateKeyPair:
|
||||||
@ -57,10 +57,7 @@ class CertificateBuilder:
|
|||||||
one_day = datetime.timedelta(1, 0, 0)
|
one_day = datetime.timedelta(1, 0, 0)
|
||||||
self.__private_key = self.generate_private_key()
|
self.__private_key = self.generate_private_key()
|
||||||
self.__public_key = self.__private_key.public_key()
|
self.__public_key = self.__private_key.public_key()
|
||||||
alt_names: list[x509.GeneralName] = []
|
alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []]
|
||||||
for alt_name in subject_alt_names or []:
|
|
||||||
if alt_name.strip() != "":
|
|
||||||
alt_names.append(x509.DNSName(alt_name))
|
|
||||||
self.__builder = (
|
self.__builder = (
|
||||||
x509.CertificateBuilder()
|
x509.CertificateBuilder()
|
||||||
.subject_name(
|
.subject_name(
|
||||||
@ -79,15 +76,12 @@ class CertificateBuilder:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.add_extension(x509.SubjectAlternativeName(alt_names), critical=True)
|
||||||
.not_valid_before(datetime.datetime.today() - one_day)
|
.not_valid_before(datetime.datetime.today() - one_day)
|
||||||
.not_valid_after(datetime.datetime.today() + datetime.timedelta(days=validity_days))
|
.not_valid_after(datetime.datetime.today() + datetime.timedelta(days=validity_days))
|
||||||
.serial_number(int(uuid.uuid4()))
|
.serial_number(int(uuid.uuid4()))
|
||||||
.public_key(self.__public_key)
|
.public_key(self.__public_key)
|
||||||
)
|
)
|
||||||
if alt_names:
|
|
||||||
self.__builder = self.__builder.add_extension(
|
|
||||||
x509.SubjectAlternativeName(alt_names), critical=True
|
|
||||||
)
|
|
||||||
self.__certificate = self.__builder.sign(
|
self.__certificate = self.__builder.sign(
|
||||||
private_key=self.__private_key,
|
private_key=self.__private_key,
|
||||||
algorithm=hashes.SHA256(),
|
algorithm=hashes.SHA256(),
|
||||||
|
@ -6,6 +6,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = []
|
dependencies = []
|
||||||
@ -35,10 +36,7 @@ class Migration(migrations.Migration):
|
|||||||
models.TextField(
|
models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
help_text=(
|
help_text="Optional Private Key. If this is set, you can use this keypair for encryption.",
|
||||||
"Optional Private Key. If this is set, you can use this keypair for"
|
|
||||||
" encryption."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -6,6 +6,7 @@ from authentik.lib.generators import generate_id
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_crypto", "0001_initial"),
|
("authentik_crypto", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_crypto", "0002_create_self_signed_kp"),
|
("authentik_crypto", "0002_create_self_signed_kp"),
|
||||||
]
|
]
|
||||||
@ -14,12 +15,7 @@ class Migration(migrations.Migration):
|
|||||||
name="managed",
|
name="managed",
|
||||||
field=models.TextField(
|
field=models.TextField(
|
||||||
default=None,
|
default=None,
|
||||||
help_text=(
|
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||||
"Objects which are managed by authentik. These objects are created and updated"
|
|
||||||
" automatically. This is flag only indicates that an object can be overwritten"
|
|
||||||
" by migrations. You can still modify the objects via the API, but expect"
|
|
||||||
" changes to be overwritten in a later update."
|
|
||||||
),
|
|
||||||
null=True,
|
null=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="Managed by authentik",
|
verbose_name="Managed by authentik",
|
||||||
|
@ -98,5 +98,6 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
|||||||
return f"Certificate-Key Pair {self.name}"
|
return f"Certificate-Key Pair {self.name}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Certificate-Key Pair")
|
verbose_name = _("Certificate-Key Pair")
|
||||||
verbose_name_plural = _("Certificate-Key Pairs")
|
verbose_name_plural = _("Certificate-Key Pairs")
|
||||||
|
@ -4,8 +4,6 @@ from json import loads
|
|||||||
from os import makedirs
|
from os import makedirs
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from cryptography.x509.extensions import SubjectAlternativeName
|
|
||||||
from cryptography.x509.general_name import DNSName
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@ -16,7 +14,7 @@ from authentik.crypto.builder import CertificateBuilder
|
|||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_key
|
||||||
from authentik.providers.oauth2.models import OAuth2Provider
|
from authentik.providers.oauth2.models import OAuth2Provider
|
||||||
|
|
||||||
|
|
||||||
@ -56,8 +54,8 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_builder(self):
|
def test_builder(self):
|
||||||
"""Test Builder"""
|
"""Test Builder"""
|
||||||
name = generate_id()
|
builder = CertificateBuilder()
|
||||||
builder = CertificateBuilder(name)
|
builder.common_name = "test-cert"
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
builder.save()
|
builder.save()
|
||||||
builder.build(
|
builder.build(
|
||||||
@ -66,49 +64,17 @@ class TestCrypto(APITestCase):
|
|||||||
)
|
)
|
||||||
instance = builder.save()
|
instance = builder.save()
|
||||||
now = datetime.datetime.today()
|
now = datetime.datetime.today()
|
||||||
self.assertEqual(instance.name, name)
|
self.assertEqual(instance.name, "test-cert")
|
||||||
self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
|
self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
|
||||||
|
|
||||||
def test_builder_api(self):
|
def test_builder_api(self):
|
||||||
"""Test Builder (via API)"""
|
"""Test Builder (via API)"""
|
||||||
self.client.force_login(create_test_admin_user())
|
self.client.force_login(create_test_admin_user())
|
||||||
name = generate_id()
|
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("authentik_api:certificatekeypair-generate"),
|
reverse("authentik_api:certificatekeypair-generate"),
|
||||||
data={"common_name": name, "subject_alt_name": "bar,baz", "validity_days": 3},
|
data={"common_name": "foo", "subject_alt_name": "bar,baz", "validity_days": 3},
|
||||||
)
|
)
|
||||||
key = CertificateKeyPair.objects.filter(name=name).first()
|
self.assertTrue(CertificateKeyPair.objects.filter(name="foo").exists())
|
||||||
self.assertIsNotNone(key)
|
|
||||||
ext: SubjectAlternativeName = key.certificate.extensions[0].value
|
|
||||||
self.assertIsInstance(ext, SubjectAlternativeName)
|
|
||||||
self.assertIsInstance(ext[0], DNSName)
|
|
||||||
self.assertEqual(ext[0].value, "bar")
|
|
||||||
self.assertIsInstance(ext[1], DNSName)
|
|
||||||
self.assertEqual(ext[1].value, "baz")
|
|
||||||
|
|
||||||
def test_builder_api_empty_san(self):
|
|
||||||
"""Test Builder (via API)"""
|
|
||||||
self.client.force_login(create_test_admin_user())
|
|
||||||
name = generate_id()
|
|
||||||
self.client.post(
|
|
||||||
reverse("authentik_api:certificatekeypair-generate"),
|
|
||||||
data={"common_name": name, "subject_alt_name": "", "validity_days": 3},
|
|
||||||
)
|
|
||||||
key = CertificateKeyPair.objects.filter(name=name).first()
|
|
||||||
self.assertIsNotNone(key)
|
|
||||||
self.assertEqual(len(key.certificate.extensions), 0)
|
|
||||||
|
|
||||||
def test_builder_api_empty_san_multiple(self):
|
|
||||||
"""Test Builder (via API)"""
|
|
||||||
self.client.force_login(create_test_admin_user())
|
|
||||||
name = generate_id()
|
|
||||||
self.client.post(
|
|
||||||
reverse("authentik_api:certificatekeypair-generate"),
|
|
||||||
data={"common_name": name, "subject_alt_name": ", ", "validity_days": 3},
|
|
||||||
)
|
|
||||||
key = CertificateKeyPair.objects.filter(name=name).first()
|
|
||||||
self.assertIsNotNone(key)
|
|
||||||
self.assertEqual(len(key.certificate.extensions), 0)
|
|
||||||
|
|
||||||
def test_builder_api_invalid(self):
|
def test_builder_api_invalid(self):
|
||||||
"""Test Builder (via API) (invalid)"""
|
"""Test Builder (via API) (invalid)"""
|
||||||
@ -227,8 +193,8 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_discovery(self):
|
def test_discovery(self):
|
||||||
"""Test certificate discovery"""
|
"""Test certificate discovery"""
|
||||||
name = generate_id()
|
builder = CertificateBuilder()
|
||||||
builder = CertificateBuilder(name)
|
builder.common_name = "test-cert"
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
builder.save()
|
builder.save()
|
||||||
builder.build(
|
builder.build(
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
"""Events API Views"""
|
"""Events API Views"""
|
||||||
from datetime import timedelta
|
|
||||||
from json import loads
|
from json import loads
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
from django.db.models.aggregates import Count
|
from django.db.models.aggregates import Count
|
||||||
from django.db.models.fields.json import KeyTextTransform
|
from django.db.models.fields.json import KeyTextTransform
|
||||||
from django.db.models.functions import ExtractDay
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
@ -25,6 +23,7 @@ class EventSerializer(ModelSerializer):
|
|||||||
"""Event Serializer"""
|
"""Event Serializer"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Event
|
model = Event
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -82,6 +81,7 @@ class EventsFilter(django_filters.FilterSet):
|
|||||||
label="Tenant name",
|
label="Tenant name",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def filter_context_model_pk(self, queryset, name, value):
|
def filter_context_model_pk(self, queryset, name, value):
|
||||||
"""Because we store the PK as UUID.hex,
|
"""Because we store the PK as UUID.hex,
|
||||||
we need to remove the dashes that a client may send. We can't use a
|
we need to remove the dashes that a client may send. We can't use a
|
||||||
@ -178,7 +178,7 @@ class EventViewSet(ModelViewSet):
|
|||||||
get_objects_for_user(request.user, "authentik_events.view_event")
|
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||||
.filter(action=filtered_action)
|
.filter(action=filtered_action)
|
||||||
.filter(**query)
|
.filter(**query)
|
||||||
.get_events_per(timedelta(weeks=4), ExtractDay, 30)
|
.get_events_per_day()
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
||||||
|
@ -10,6 +10,7 @@ class NotificationWebhookMappingSerializer(ModelSerializer):
|
|||||||
"""NotificationWebhookMapping Serializer"""
|
"""NotificationWebhookMapping Serializer"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = NotificationWebhookMapping
|
model = NotificationWebhookMapping
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
|
@ -13,6 +13,7 @@ class NotificationRuleSerializer(ModelSerializer):
|
|||||||
group_obj = GroupSerializer(read_only=True, source="group")
|
group_obj = GroupSerializer(read_only=True, source="group")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = NotificationRule
|
model = NotificationRule
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
|
@ -43,6 +43,7 @@ class NotificationTransportSerializer(ModelSerializer):
|
|||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = NotificationTransport
|
model = NotificationTransport
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
@ -79,6 +80,7 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
|
|||||||
request=OpenApiTypes.NONE,
|
request=OpenApiTypes.NONE,
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
|
||||||
|
# pylint: disable=invalid-name, unused-argument
|
||||||
def test(self, request: Request, pk=None) -> Response:
|
def test(self, request: Request, pk=None) -> Response:
|
||||||
"""Send example notification using selected transport. Requires
|
"""Send example notification using selected transport. Requires
|
||||||
Modify permissions."""
|
Modify permissions."""
|
||||||
|
@ -25,6 +25,7 @@ class NotificationSerializer(ModelSerializer):
|
|||||||
event = EventSerializer(required=False)
|
event = EventSerializer(required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Notification
|
model = Notification
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
|
@ -12,22 +12,12 @@ from django.http import HttpRequest, HttpResponse
|
|||||||
from django_otp.plugins.otp_static.models import StaticToken
|
from django_otp.plugins.otp_static.models import StaticToken
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
|
|
||||||
from authentik.core.models import (
|
from authentik.core.models import AuthenticatedSession, User
|
||||||
AuthenticatedSession,
|
|
||||||
PropertyMapping,
|
|
||||||
Provider,
|
|
||||||
Source,
|
|
||||||
User,
|
|
||||||
UserSourceConnection,
|
|
||||||
)
|
|
||||||
from authentik.events.models import Event, EventAction, Notification
|
from authentik.events.models import Event, EventAction, Notification
|
||||||
from authentik.events.utils import model_to_dict
|
from authentik.events.utils import model_to_dict
|
||||||
from authentik.flows.models import FlowToken, Stage
|
from authentik.flows.models import FlowToken
|
||||||
from authentik.lib.sentry import before_send
|
from authentik.lib.sentry import before_send
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
from authentik.outposts.models import OutpostServiceConnection
|
|
||||||
from authentik.policies.models import Policy, PolicyBindingModel
|
|
||||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
|
||||||
|
|
||||||
IGNORED_MODELS = (
|
IGNORED_MODELS = (
|
||||||
Event,
|
Event,
|
||||||
@ -37,17 +27,6 @@ IGNORED_MODELS = (
|
|||||||
StaticToken,
|
StaticToken,
|
||||||
Session,
|
Session,
|
||||||
FlowToken,
|
FlowToken,
|
||||||
Provider,
|
|
||||||
Source,
|
|
||||||
PropertyMapping,
|
|
||||||
UserSourceConnection,
|
|
||||||
Stage,
|
|
||||||
OutpostServiceConnection,
|
|
||||||
Policy,
|
|
||||||
PolicyBindingModel,
|
|
||||||
AuthorizationCode,
|
|
||||||
AccessToken,
|
|
||||||
RefreshToken,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -55,7 +34,7 @@ def should_log_model(model: Model) -> bool:
|
|||||||
"""Return true if operation on `model` should be logged"""
|
"""Return true if operation on `model` should be logged"""
|
||||||
if model.__module__.startswith("silk"):
|
if model.__module__.startswith("silk"):
|
||||||
return False
|
return False
|
||||||
return model.__class__ not in IGNORED_MODELS
|
return not isinstance(model, IGNORED_MODELS)
|
||||||
|
|
||||||
|
|
||||||
class EventNewThread(Thread):
|
class EventNewThread(Thread):
|
||||||
@ -122,6 +101,7 @@ class AuditMiddleware:
|
|||||||
self.disconnect(request)
|
self.disconnect(request)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def process_exception(self, request: HttpRequest, exception: Exception):
|
def process_exception(self, request: HttpRequest, exception: Exception):
|
||||||
"""Disconnect handlers in case of exception"""
|
"""Disconnect handlers in case of exception"""
|
||||||
self.disconnect(request)
|
self.disconnect(request)
|
||||||
@ -145,6 +125,7 @@ class AuditMiddleware:
|
|||||||
thread.run()
|
thread.run()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def post_save_handler(
|
def post_save_handler(
|
||||||
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
||||||
):
|
):
|
||||||
@ -156,6 +137,7 @@ class AuditMiddleware:
|
|||||||
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
|
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||||
"""Signal handler for all object's pre_delete"""
|
"""Signal handler for all object's pre_delete"""
|
||||||
if not should_log_model(instance): # pragma: no cover
|
if not should_log_model(instance): # pragma: no cover
|
||||||
|
@ -100,6 +100,7 @@ def update_expires(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
replaces = [
|
replaces = [
|
||||||
("authentik_events", "0001_initial"),
|
("authentik_events", "0001_initial"),
|
||||||
("authentik_events", "0002_auto_20200918_2116"),
|
("authentik_events", "0002_auto_20200918_2116"),
|
||||||
@ -244,19 +245,14 @@ class Migration(migrations.Migration):
|
|||||||
models.TextField(
|
models.TextField(
|
||||||
choices=[("notice", "Notice"), ("warning", "Warning"), ("alert", "Alert")],
|
choices=[("notice", "Notice"), ("warning", "Warning"), ("alert", "Alert")],
|
||||||
default="notice",
|
default="notice",
|
||||||
help_text=(
|
help_text="Controls which severity level the created notifications will have.",
|
||||||
"Controls which severity level the created notifications will have."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"group",
|
"group",
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=(
|
help_text="Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent.",
|
||||||
"Define which group of users this notification should be sent and shown"
|
|
||||||
" to. If left empty, Notification won't ben sent."
|
|
||||||
),
|
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
to="authentik_core.group",
|
to="authentik_core.group",
|
||||||
@ -265,10 +261,7 @@ class Migration(migrations.Migration):
|
|||||||
(
|
(
|
||||||
"transports",
|
"transports",
|
||||||
models.ManyToManyField(
|
models.ManyToManyField(
|
||||||
help_text=(
|
help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
|
||||||
"Select which transports should be used to notify the user. If none are"
|
|
||||||
" selected, the notification will only be shown in the authentik UI."
|
|
||||||
),
|
|
||||||
to="authentik_events.NotificationTransport",
|
to="authentik_events.NotificationTransport",
|
||||||
blank=True,
|
blank=True,
|
||||||
),
|
),
|
||||||
@ -324,10 +317,7 @@ class Migration(migrations.Migration):
|
|||||||
name="send_once",
|
name="send_once",
|
||||||
field=models.BooleanField(
|
field=models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text=(
|
help_text="Only send notification once, for example when sending a webhook into a chat channel.",
|
||||||
"Only send notification once, for example when sending a webhook into a chat"
|
|
||||||
" channel."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.RunPython(
|
migrations.RunPython(
|
||||||
|
@ -3,6 +3,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_events", "0001_squashed_0019_alter_notificationtransport_webhook_url"),
|
("authentik_events", "0001_squashed_0019_alter_notificationtransport_webhook_url"),
|
||||||
]
|
]
|
||||||
|
@ -11,7 +11,8 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count, ExpressionWrapper, F
|
from django.db.models import Count, ExpressionWrapper, F
|
||||||
from django.db.models.fields import DurationField
|
from django.db.models.fields import DurationField
|
||||||
from django.db.models.functions import Extract
|
from django.db.models.functions import ExtractHour
|
||||||
|
from django.db.models.functions.datetime import ExtractDay
|
||||||
from django.db.models.manager import Manager
|
from django.db.models.manager import Manager
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@ -110,35 +111,48 @@ class EventAction(models.TextChoices):
|
|||||||
class EventQuerySet(QuerySet):
|
class EventQuerySet(QuerySet):
|
||||||
"""Custom events query set with helper functions"""
|
"""Custom events query set with helper functions"""
|
||||||
|
|
||||||
def get_events_per(
|
def get_events_per_hour(self) -> list[dict[str, int]]:
|
||||||
self,
|
|
||||||
time_since: timedelta,
|
|
||||||
extract: Extract,
|
|
||||||
data_points: int,
|
|
||||||
) -> list[dict[str, int]]:
|
|
||||||
"""Get event count by hour in the last day, fill with zeros"""
|
"""Get event count by hour in the last day, fill with zeros"""
|
||||||
_now = now()
|
date_from = now() - timedelta(days=1)
|
||||||
max_since = timedelta(days=60)
|
|
||||||
# Allow maximum of 60 days to limit load
|
|
||||||
if time_since.total_seconds() > max_since.total_seconds():
|
|
||||||
time_since = max_since
|
|
||||||
date_from = _now - time_since
|
|
||||||
result = (
|
result = (
|
||||||
self.filter(created__gte=date_from)
|
self.filter(created__gte=date_from)
|
||||||
.annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
|
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
||||||
.annotate(age_interval=extract("age"))
|
.annotate(age_hours=ExtractHour("age"))
|
||||||
.values("age_interval")
|
.values("age_hours")
|
||||||
.annotate(count=Count("pk"))
|
.annotate(count=Count("pk"))
|
||||||
.order_by("age_interval")
|
.order_by("age_hours")
|
||||||
)
|
)
|
||||||
data = Counter({int(d["age_interval"]): d["count"] for d in result})
|
data = Counter({int(d["age_hours"]): d["count"] for d in result})
|
||||||
results = []
|
results = []
|
||||||
interval_delta = time_since / data_points
|
_now = now()
|
||||||
for interval in range(1, -data_points, -1):
|
for hour in range(0, -24, -1):
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000,
|
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
|
||||||
"y_cord": data[interval * -1],
|
"y_cord": data[hour * -1],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_events_per_day(self) -> list[dict[str, int]]:
|
||||||
|
"""Get event count by hour in the last day, fill with zeros"""
|
||||||
|
date_from = now() - timedelta(weeks=4)
|
||||||
|
result = (
|
||||||
|
self.filter(created__gte=date_from)
|
||||||
|
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
||||||
|
.annotate(age_days=ExtractDay("age"))
|
||||||
|
.values("age_days")
|
||||||
|
.annotate(count=Count("pk"))
|
||||||
|
.order_by("age_days")
|
||||||
|
)
|
||||||
|
data = Counter({int(d["age_days"]): d["count"] for d in result})
|
||||||
|
results = []
|
||||||
|
_now = now()
|
||||||
|
for day in range(0, -30, -1):
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000,
|
||||||
|
"y_cord": data[day * -1],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
@ -151,14 +165,13 @@ class EventManager(Manager):
|
|||||||
"""use custom queryset"""
|
"""use custom queryset"""
|
||||||
return EventQuerySet(self.model, using=self._db)
|
return EventQuerySet(self.model, using=self._db)
|
||||||
|
|
||||||
def get_events_per(
|
def get_events_per_hour(self) -> list[dict[str, int]]:
|
||||||
self,
|
|
||||||
time_since: timedelta,
|
|
||||||
extract: Extract,
|
|
||||||
data_points: int,
|
|
||||||
) -> list[dict[str, int]]:
|
|
||||||
"""Wrap method from queryset"""
|
"""Wrap method from queryset"""
|
||||||
return self.get_queryset().get_events_per(time_since, extract, data_points)
|
return self.get_queryset().get_events_per_hour()
|
||||||
|
|
||||||
|
def get_events_per_day(self) -> list[dict[str, int]]:
|
||||||
|
"""Wrap method from queryset"""
|
||||||
|
return self.get_queryset().get_events_per_day()
|
||||||
|
|
||||||
|
|
||||||
class Event(SerializerModel, ExpiringModel):
|
class Event(SerializerModel, ExpiringModel):
|
||||||
@ -283,6 +296,7 @@ class Event(SerializerModel, ExpiringModel):
|
|||||||
return f"Event action={self.action} user={self.user} context={self.context}"
|
return f"Event action={self.action} user={self.user} context={self.context}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Event")
|
verbose_name = _("Event")
|
||||||
verbose_name_plural = _("Events")
|
verbose_name_plural = _("Events")
|
||||||
|
|
||||||
@ -361,9 +375,7 @@ class NotificationTransport(SerializerModel):
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except RequestException as exc:
|
except RequestException as exc:
|
||||||
raise NotificationTransportError(
|
raise NotificationTransportError(exc.response.text) from exc
|
||||||
exc.response.text if exc.response else str(exc)
|
|
||||||
) from exc
|
|
||||||
return [
|
return [
|
||||||
response.status_code,
|
response.status_code,
|
||||||
response.text,
|
response.text,
|
||||||
@ -449,7 +461,7 @@ class NotificationTransport(SerializerModel):
|
|||||||
# pyright: reportGeneralTypeIssues=false
|
# pyright: reportGeneralTypeIssues=false
|
||||||
return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter
|
return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter
|
||||||
except (SMTPException, ConnectionError, OSError) as exc:
|
except (SMTPException, ConnectionError, OSError) as exc:
|
||||||
raise NotificationTransportError(exc) from exc
|
raise NotificationTransportError from exc
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> "Serializer":
|
def serializer(self) -> "Serializer":
|
||||||
@ -461,6 +473,7 @@ class NotificationTransport(SerializerModel):
|
|||||||
return f"Notification Transport {self.name}"
|
return f"Notification Transport {self.name}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Notification Transport")
|
verbose_name = _("Notification Transport")
|
||||||
verbose_name_plural = _("Notification Transports")
|
verbose_name_plural = _("Notification Transports")
|
||||||
|
|
||||||
@ -495,6 +508,7 @@ class Notification(SerializerModel):
|
|||||||
return f"Notification for user {self.user}: {body_trunc}"
|
return f"Notification for user {self.user}: {body_trunc}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Notification")
|
verbose_name = _("Notification")
|
||||||
verbose_name_plural = _("Notifications")
|
verbose_name_plural = _("Notifications")
|
||||||
|
|
||||||
@ -506,8 +520,10 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
|
|||||||
transports = models.ManyToManyField(
|
transports = models.ManyToManyField(
|
||||||
NotificationTransport,
|
NotificationTransport,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"Select which transports should be used to notify the user. If none are "
|
(
|
||||||
"selected, the notification will only be shown in the authentik UI."
|
"Select which transports should be used to notify the user. If none are "
|
||||||
|
"selected, the notification will only be shown in the authentik UI."
|
||||||
|
)
|
||||||
),
|
),
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
@ -519,8 +535,10 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
|
|||||||
group = models.ForeignKey(
|
group = models.ForeignKey(
|
||||||
Group,
|
Group,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"Define which group of users this notification should be sent and shown to. "
|
(
|
||||||
"If left empty, Notification won't ben sent."
|
"Define which group of users this notification should be sent and shown to. "
|
||||||
|
"If left empty, Notification won't ben sent."
|
||||||
|
)
|
||||||
),
|
),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
@ -537,6 +555,7 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
|
|||||||
return f"Notification Rule {self.name}"
|
return f"Notification Rule {self.name}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Notification Rule")
|
verbose_name = _("Notification Rule")
|
||||||
verbose_name_plural = _("Notification Rules")
|
verbose_name_plural = _("Notification Rules")
|
||||||
|
|
||||||
@ -558,5 +577,6 @@ class NotificationWebhookMapping(PropertyMapping):
|
|||||||
return f"Webhook Mapping {self.name}"
|
return f"Webhook Mapping {self.name}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Webhook Mapping")
|
verbose_name = _("Webhook Mapping")
|
||||||
verbose_name_plural = _("Webhook Mappings")
|
verbose_name_plural = _("Webhook Mappings")
|
||||||
|
@ -63,6 +63,11 @@ class TaskInfo:
|
|||||||
|
|
||||||
task_description: Optional[str] = field(default=None)
|
task_description: Optional[str] = field(default=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def html_name(self) -> list[str]:
|
||||||
|
"""Get task_name, but split on underscores, so we can join in the html template."""
|
||||||
|
return self.task_name.split("_")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def all() -> dict[str, "TaskInfo"]:
|
def all() -> dict[str, "TaskInfo"]:
|
||||||
"""Get all TaskInfo objects"""
|
"""Get all TaskInfo objects"""
|
||||||
@ -77,7 +82,7 @@ class TaskInfo:
|
|||||||
"""Delete task info from cache"""
|
"""Delete task info from cache"""
|
||||||
return cache.delete(CACHE_KEY_PREFIX + self.task_name)
|
return cache.delete(CACHE_KEY_PREFIX + self.task_name)
|
||||||
|
|
||||||
def update_metrics(self):
|
def set_prom_metrics(self):
|
||||||
"""Update prometheus metrics"""
|
"""Update prometheus metrics"""
|
||||||
start = default_timer()
|
start = default_timer()
|
||||||
if hasattr(self, "start_timestamp"):
|
if hasattr(self, "start_timestamp"):
|
||||||
@ -96,9 +101,9 @@ class TaskInfo:
|
|||||||
"""Save task into cache"""
|
"""Save task into cache"""
|
||||||
key = CACHE_KEY_PREFIX + self.task_name
|
key = CACHE_KEY_PREFIX + self.task_name
|
||||||
if self.result.uid:
|
if self.result.uid:
|
||||||
key += f":{self.result.uid}"
|
key += f"/{self.result.uid}"
|
||||||
self.task_name += f":{self.result.uid}"
|
self.task_name += f"_{self.result.uid}"
|
||||||
self.update_metrics()
|
self.set_prom_metrics()
|
||||||
cache.set(key, self, timeout=timeout_hours * 60 * 60)
|
cache.set(key, self, timeout=timeout_hours * 60 * 60)
|
||||||
|
|
||||||
|
|
||||||
@ -173,7 +178,7 @@ class MonitoredTask(Task):
|
|||||||
).save(self.result_timeout_hours)
|
).save(self.result_timeout_hours)
|
||||||
Event.new(
|
Event.new(
|
||||||
EventAction.SYSTEM_TASK_EXCEPTION,
|
EventAction.SYSTEM_TASK_EXCEPTION,
|
||||||
message=f"Task {self.__name__} encountered an error: {exception_to_string(exc)}",
|
message=(f"Task {self.__name__} encountered an error: {exception_to_string(exc)}"),
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
|
@ -22,6 +22,7 @@ SESSION_LOGIN_EVENT = "login_event"
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
||||||
"""Log successful login"""
|
"""Log successful login"""
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
@ -44,12 +45,14 @@ def get_login_event(request: HttpRequest) -> Optional[Event]:
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(user_logged_out)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||||
"""Log successfully logout"""
|
"""Log successfully logout"""
|
||||||
Event.new(EventAction.LOGOUT).from_http(request, user=user)
|
Event.new(EventAction.LOGOUT).from_http(request, user=user)
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_write)
|
@receiver(user_write)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs):
|
def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs):
|
||||||
"""Log User write"""
|
"""Log User write"""
|
||||||
data["created"] = kwargs.get("created", False)
|
data["created"] = kwargs.get("created", False)
|
||||||
@ -57,6 +60,7 @@ def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any]
|
|||||||
|
|
||||||
|
|
||||||
@receiver(login_failed)
|
@receiver(login_failed)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def on_login_failed(
|
def on_login_failed(
|
||||||
signal,
|
signal,
|
||||||
sender,
|
sender,
|
||||||
@ -70,6 +74,7 @@ def on_login_failed(
|
|||||||
|
|
||||||
|
|
||||||
@receiver(invitation_used)
|
@receiver(invitation_used)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
|
def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
|
||||||
"""Log Invitation usage"""
|
"""Log Invitation usage"""
|
||||||
Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.invite_uuid.hex).from_http(
|
Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.invite_uuid.hex).from_http(
|
||||||
@ -78,18 +83,21 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_
|
|||||||
|
|
||||||
|
|
||||||
@receiver(password_changed)
|
@receiver(password_changed)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def on_password_changed(sender, user: User, password: str, **_):
|
def on_password_changed(sender, user: User, password: str, **_):
|
||||||
"""Log password change"""
|
"""Log password change"""
|
||||||
Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
|
Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Event)
|
@receiver(post_save, sender=Event)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def event_post_save_notification(sender, instance: Event, **_):
|
def event_post_save_notification(sender, instance: Event, **_):
|
||||||
"""Start task to check if any policies trigger an notification on this event"""
|
"""Start task to check if any policies trigger an notification on this event"""
|
||||||
event_notification_handler.delay(instance.event_uuid.hex)
|
event_notification_handler.delay(instance.event_uuid.hex)
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=User)
|
@receiver(pre_delete, sender=User)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
def event_user_pre_delete_cleanup(sender, instance: User, **_):
|
def event_user_pre_delete_cleanup(sender, instance: User, **_):
|
||||||
"""If gdpr_compliance is enabled, remove all the user's events"""
|
"""If gdpr_compliance is enabled, remove all the user's events"""
|
||||||
gdpr_cleanup.delay(instance.pk)
|
gdpr_cleanup.delay(instance.pk)
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user