Compare commits
5 Commits
version-20
...
version-20
Author | SHA1 | Date | |
---|---|---|---|
fe5d22ce6c | |||
0e30b6ee55 | |||
6cbba45291 | |||
ba023a3bba | |||
6c805bcf32 |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2022.3.3
|
current_version = 2021.8.5
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
||||||
@ -17,7 +17,7 @@ values =
|
|||||||
beta
|
beta
|
||||||
stable
|
stable
|
||||||
|
|
||||||
[bumpversion:file:pyproject.toml]
|
[bumpversion:file:website/docs/installation/docker-compose.md]
|
||||||
|
|
||||||
[bumpversion:file:docker-compose.yml]
|
[bumpversion:file:docker-compose.yml]
|
||||||
|
|
||||||
@ -30,3 +30,7 @@ values =
|
|||||||
[bumpversion:file:internal/constants/constants.go]
|
[bumpversion:file:internal/constants/constants.go]
|
||||||
|
|
||||||
[bumpversion:file:web/src/constants.ts]
|
[bumpversion:file:web/src/constants.ts]
|
||||||
|
|
||||||
|
[bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md]
|
||||||
|
|
||||||
|
[bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md]
|
||||||
|
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -27,7 +27,7 @@ If applicable, add screenshots to help explain your problem.
|
|||||||
Output of docker-compose logs or kubectl logs respectively
|
Output of docker-compose logs or kubectl logs respectively
|
||||||
|
|
||||||
**Version and Deployment (please complete the following information):**
|
**Version and Deployment (please complete the following information):**
|
||||||
- authentik version: [e.g. 2021.8.5]
|
- authentik version: [e.g. 0.10.0-stable]
|
||||||
- Deployment: [e.g. docker-compose, helm]
|
- Deployment: [e.g. docker-compose, helm]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
|
2
.github/ISSUE_TEMPLATE/question.md
vendored
2
.github/ISSUE_TEMPLATE/question.md
vendored
@ -20,7 +20,7 @@ If applicable, add screenshots to help explain your problem.
|
|||||||
Output of docker-compose logs or kubectl logs respectively
|
Output of docker-compose logs or kubectl logs respectively
|
||||||
|
|
||||||
**Version and Deployment (please complete the following information):**
|
**Version and Deployment (please complete the following information):**
|
||||||
- authentik version: [e.g. 2021.8.5]
|
- authentik version: [e.g. 0.10.0-stable]
|
||||||
- Deployment: [e.g. docker-compose, helm]
|
- Deployment: [e.g. docker-compose, helm]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
|
3
.github/codespell-words.txt
vendored
3
.github/codespell-words.txt
vendored
@ -1,3 +0,0 @@
|
|||||||
keypair
|
|
||||||
keypairs
|
|
||||||
hass
|
|
1
.github/stale.yml
vendored
1
.github/stale.yml
vendored
@ -7,7 +7,6 @@ exemptLabels:
|
|||||||
- pinned
|
- pinned
|
||||||
- security
|
- security
|
||||||
- pr_wanted
|
- pr_wanted
|
||||||
- enhancement
|
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
markComment: >
|
markComment: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
This issue has been automatically marked as stale because it has not had
|
||||||
|
323
.github/workflows/ci-main.yml
vendored
323
.github/workflows/ci-main.yml
vendored
@ -18,120 +18,165 @@ env:
|
|||||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint-pylint:
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
job:
|
|
||||||
- pylint
|
|
||||||
- black
|
|
||||||
- isort
|
|
||||||
- bandit
|
|
||||||
- pyright
|
|
||||||
- pending-migrations
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-python@v3
|
- uses: actions/setup-python@v2
|
||||||
- uses: actions/setup-node@v3.0.0
|
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
python-version: '3.9'
|
||||||
- id: cache-poetry
|
- id: cache-pipenv
|
||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.6
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.local/share/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
|
||||||
run: scripts/ci_prepare.sh
|
run: scripts/ci_prepare.sh
|
||||||
- name: run job
|
- name: run pylint
|
||||||
run: poetry run make ci-${{ matrix.job }}
|
run: pipenv run pylint authentik tests lifecycle
|
||||||
|
lint-black:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: '3.9'
|
||||||
|
- id: cache-pipenv
|
||||||
|
uses: actions/cache@v2.1.6
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/virtualenvs
|
||||||
|
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
|
||||||
|
- name: prepare
|
||||||
|
env:
|
||||||
|
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
|
||||||
|
run: scripts/ci_prepare.sh
|
||||||
|
- name: run black
|
||||||
|
run: pipenv run black --check authentik tests lifecycle
|
||||||
|
lint-isort:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: '3.9'
|
||||||
|
- id: cache-pipenv
|
||||||
|
uses: actions/cache@v2.1.6
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/virtualenvs
|
||||||
|
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
|
||||||
|
- name: prepare
|
||||||
|
env:
|
||||||
|
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
|
||||||
|
run: scripts/ci_prepare.sh
|
||||||
|
- name: run isort
|
||||||
|
run: pipenv run isort --check authentik tests lifecycle
|
||||||
|
lint-bandit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: '3.9'
|
||||||
|
- id: cache-pipenv
|
||||||
|
uses: actions/cache@v2.1.6
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/virtualenvs
|
||||||
|
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
|
||||||
|
- name: prepare
|
||||||
|
env:
|
||||||
|
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
|
||||||
|
run: scripts/ci_prepare.sh
|
||||||
|
- name: run bandit
|
||||||
|
run: pipenv run bandit -r authentik tests lifecycle
|
||||||
|
lint-pyright:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: '3.9'
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '16'
|
||||||
|
- name: prepare
|
||||||
|
run: |
|
||||||
|
scripts/ci_prepare.sh
|
||||||
|
npm install -g pyright@1.1.136
|
||||||
|
- name: run bandit
|
||||||
|
run: pipenv run pyright e2e lifecycle
|
||||||
test-migrations:
|
test-migrations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-python@v3
|
- uses: actions/setup-python@v2
|
||||||
- id: cache-poetry
|
|
||||||
uses: actions/cache@v2.1.7
|
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
python-version: '3.9'
|
||||||
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
- id: cache-pipenv
|
||||||
|
uses: actions/cache@v2.1.6
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/virtualenvs
|
||||||
|
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
|
||||||
run: scripts/ci_prepare.sh
|
run: scripts/ci_prepare.sh
|
||||||
- name: run migrations
|
- name: run migrations
|
||||||
run: poetry run python -m lifecycle.migrate
|
run: pipenv run python -m lifecycle.migrate
|
||||||
test-migrations-from-stable:
|
test-migrations-from-stable:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
python-version: '3.9'
|
||||||
- uses: actions/setup-python@v3
|
|
||||||
- name: prepare variables
|
|
||||||
id: ev
|
|
||||||
run: |
|
|
||||||
python ./scripts/gh_env.py
|
|
||||||
sudo pip install -U pipenv
|
|
||||||
- id: cache-poetry
|
|
||||||
uses: actions/cache@v2.1.7
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
|
||||||
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
|
||||||
- name: checkout stable
|
- name: checkout stable
|
||||||
run: |
|
run: |
|
||||||
# Copy current, latest config to local
|
# Copy current, latest config to local
|
||||||
cp authentik/lib/default.yml local.env.yml
|
cp authentik/lib/default.yml local.env.yml
|
||||||
cp -R .github ..
|
|
||||||
cp -R scripts ..
|
|
||||||
git checkout $(git describe --abbrev=0 --match 'version/*')
|
git checkout $(git describe --abbrev=0 --match 'version/*')
|
||||||
rm -rf .github/ scripts/
|
- id: cache-pipenv
|
||||||
mv ../.github ../scripts .
|
uses: actions/cache@v2.1.6
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/virtualenvs
|
||||||
|
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
|
||||||
run: |
|
run: scripts/ci_prepare.sh
|
||||||
scripts/ci_prepare.sh
|
|
||||||
# install anyways since stable will have different dependencies
|
|
||||||
poetry install
|
|
||||||
- name: run migrations to stable
|
- name: run migrations to stable
|
||||||
run: poetry run python -m lifecycle.migrate
|
run: pipenv run python -m lifecycle.migrate
|
||||||
- name: checkout current code
|
- name: checkout current code
|
||||||
run: |
|
run: |
|
||||||
set -x
|
set -x
|
||||||
git fetch
|
git checkout $GITHUB_REF
|
||||||
git reset --hard HEAD
|
pipenv sync --dev
|
||||||
git checkout $GITHUB_SHA
|
|
||||||
poetry install
|
|
||||||
- name: prepare
|
|
||||||
env:
|
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
|
||||||
run: scripts/ci_prepare.sh
|
|
||||||
- name: migrate to latest
|
- name: migrate to latest
|
||||||
run: poetry run python -m lifecycle.migrate
|
run: pipenv run python -m lifecycle.migrate
|
||||||
test-unittest:
|
test-unittest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-python@v3
|
- uses: actions/setup-python@v2
|
||||||
- id: cache-poetry
|
|
||||||
uses: actions/cache@v2.1.7
|
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
python-version: '3.9'
|
||||||
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
- id: cache-pipenv
|
||||||
|
uses: actions/cache@v2.1.6
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/virtualenvs
|
||||||
|
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
|
||||||
run: scripts/ci_prepare.sh
|
run: scripts/ci_prepare.sh
|
||||||
- uses: testspace-com/setup-testspace@v1
|
- uses: testspace-com/setup-testspace@v1
|
||||||
with:
|
with:
|
||||||
domain: ${{github.repository_owner}}
|
domain: ${{github.repository_owner}}
|
||||||
- name: run unittest
|
- name: run unittest
|
||||||
run: |
|
run: |
|
||||||
poetry run make test
|
pipenv run make test
|
||||||
poetry run coverage xml
|
pipenv run coverage xml
|
||||||
- name: run testspace
|
- name: run testspace
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
run: |
|
run: |
|
||||||
@ -141,16 +186,18 @@ jobs:
|
|||||||
test-integration:
|
test-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-python@v3
|
- uses: actions/setup-python@v2
|
||||||
- id: cache-poetry
|
|
||||||
uses: actions/cache@v2.1.7
|
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
python-version: '3.9'
|
||||||
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
- id: cache-pipenv
|
||||||
|
uses: actions/cache@v2.1.6
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/virtualenvs
|
||||||
|
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
|
||||||
run: scripts/ci_prepare.sh
|
run: scripts/ci_prepare.sh
|
||||||
- uses: testspace-com/setup-testspace@v1
|
- uses: testspace-com/setup-testspace@v1
|
||||||
with:
|
with:
|
||||||
@ -159,20 +206,22 @@ jobs:
|
|||||||
uses: helm/kind-action@v1.2.0
|
uses: helm/kind-action@v1.2.0
|
||||||
- name: run integration
|
- name: run integration
|
||||||
run: |
|
run: |
|
||||||
poetry run make test-integration
|
pipenv run make test-integration
|
||||||
poetry run coverage xml
|
pipenv run coverage xml
|
||||||
- name: run testspace
|
- name: run testspace
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
run: |
|
run: |
|
||||||
testspace [integration]unittest.xml --link=codecov
|
testspace [integration]unittest.xml --link=codecov
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v2
|
uses: codecov/codecov-action@v2
|
||||||
test-e2e-provider:
|
test-e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-python@v3
|
- uses: actions/setup-python@v2
|
||||||
- uses: actions/setup-node@v3.0.0
|
with:
|
||||||
|
python-version: '3.9'
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -180,19 +229,19 @@ jobs:
|
|||||||
- uses: testspace-com/setup-testspace@v1
|
- uses: testspace-com/setup-testspace@v1
|
||||||
with:
|
with:
|
||||||
domain: ${{github.repository_owner}}
|
domain: ${{github.repository_owner}}
|
||||||
- id: cache-poetry
|
- id: cache-pipenv
|
||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.6
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
path: ~/.local/share/virtualenvs
|
||||||
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
|
||||||
- name: prepare
|
- name: prepare
|
||||||
env:
|
env:
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
|
||||||
run: |
|
run: |
|
||||||
scripts/ci_prepare.sh
|
scripts/ci_prepare.sh
|
||||||
docker-compose -f tests/e2e/docker-compose.yml up -d
|
docker-compose -f tests/e2e/ci.docker-compose.yml up -d
|
||||||
- id: cache-web
|
- id: cache-web
|
||||||
uses: actions/cache@v2.1.7
|
uses: actions/cache@v2.1.6
|
||||||
with:
|
with:
|
||||||
path: web/dist
|
path: web/dist
|
||||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
||||||
@ -204,106 +253,50 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
poetry run make test-e2e-provider
|
pipenv run make test-e2e
|
||||||
poetry run coverage xml
|
pipenv run coverage xml
|
||||||
- name: run testspace
|
- name: run testspace
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
run: |
|
run: |
|
||||||
testspace [e2e-provider]unittest.xml --link=codecov
|
testspace [e2e]unittest.xml --link=codecov
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v2
|
uses: codecov/codecov-action@v2
|
||||||
test-e2e-rest:
|
build:
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-python@v3
|
|
||||||
- uses: actions/setup-node@v3.0.0
|
|
||||||
with:
|
|
||||||
node-version: '16'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: web/package-lock.json
|
|
||||||
- uses: testspace-com/setup-testspace@v1
|
|
||||||
with:
|
|
||||||
domain: ${{github.repository_owner}}
|
|
||||||
- id: cache-poetry
|
|
||||||
uses: actions/cache@v2.1.7
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
|
||||||
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
|
||||||
- name: prepare
|
|
||||||
env:
|
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
|
||||||
run: |
|
|
||||||
scripts/ci_prepare.sh
|
|
||||||
docker-compose -f tests/e2e/docker-compose.yml up -d
|
|
||||||
- id: cache-web
|
|
||||||
uses: actions/cache@v2.1.7
|
|
||||||
with:
|
|
||||||
path: web/dist
|
|
||||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
|
||||||
- name: prepare web ui
|
|
||||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
cd web
|
|
||||||
npm i
|
|
||||||
npm run build
|
|
||||||
- name: run e2e
|
|
||||||
run: |
|
|
||||||
poetry run make test-e2e-rest
|
|
||||||
poetry run coverage xml
|
|
||||||
- name: run testspace
|
|
||||||
if: ${{ always() }}
|
|
||||||
run: |
|
|
||||||
testspace [e2e-rest]unittest.xml --link=codecov
|
|
||||||
- if: ${{ always() }}
|
|
||||||
uses: codecov/codecov-action@v2
|
|
||||||
ci-core-mark:
|
|
||||||
needs:
|
needs:
|
||||||
- lint
|
- lint-pylint
|
||||||
|
- lint-black
|
||||||
|
- lint-isort
|
||||||
|
- lint-bandit
|
||||||
|
- lint-pyright
|
||||||
- test-migrations
|
- test-migrations
|
||||||
- test-migrations-from-stable
|
- test-migrations-from-stable
|
||||||
- test-unittest
|
- test-unittest
|
||||||
- test-integration
|
- test-integration
|
||||||
- test-e2e-rest
|
- test-e2e
|
||||||
- test-e2e-provider
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- uses: actions/checkout@v2
|
||||||
build:
|
|
||||||
needs: ci-core-mark
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 120
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
arch:
|
|
||||||
- 'linux/amd64'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.HARBOR_USERNAME }}
|
||||||
run: |
|
run: |
|
||||||
python ./scripts/gh_env.py
|
python ./scripts/gh_do_set_branch.py
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v1
|
||||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: beryju.org
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ secrets.HARBOR_USERNAME }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.HARBOR_PASSWORD }}
|
||||||
- name: Building Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}
|
beryju.org/authentik/server:gh-${{ steps.ev.outputs.branchName }}
|
||||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
|
beryju.org/authentik/server:gh-${{ steps.ev.outputs.branchName }}-${{ steps.ev.outputs.timestamp }}
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||||
platforms: ${{ matrix.arch }}
|
|
||||||
|
95
.github/workflows/ci-outpost.yml
vendored
95
.github/workflows/ci-outpost.yml
vendored
@ -14,10 +14,13 @@ jobs:
|
|||||||
lint-golint:
|
lint-golint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-go@v2
|
- uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: '^1.16.3'
|
||||||
|
- name: Generate API
|
||||||
|
run: |
|
||||||
|
make gen-outpost
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: |
|
run: |
|
||||||
# Create folder structure for go embeds
|
# Create folder structure for go embeds
|
||||||
@ -28,45 +31,19 @@ jobs:
|
|||||||
--rm \
|
--rm \
|
||||||
-v $(pwd):/app \
|
-v $(pwd):/app \
|
||||||
-w /app \
|
-w /app \
|
||||||
golangci/golangci-lint:v1.43 \
|
golangci/golangci-lint:v1.39.0 \
|
||||||
golangci-lint run -v --timeout 200s
|
golangci-lint run -v --timeout 200s
|
||||||
test-unittest:
|
build:
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-go@v2
|
|
||||||
with:
|
|
||||||
go-version: "^1.17"
|
|
||||||
- name: Get dependencies
|
|
||||||
run: |
|
|
||||||
go get github.com/axw/gocov/gocov
|
|
||||||
go get github.com/AlekSi/gocov-xml
|
|
||||||
go get github.com/jstemmer/go-junit-report
|
|
||||||
- name: Go unittests
|
|
||||||
run: |
|
|
||||||
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... | go-junit-report > junit.xml
|
|
||||||
ci-outpost-mark:
|
|
||||||
needs:
|
needs:
|
||||||
- lint-golint
|
- lint-golint
|
||||||
- test-unittest
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo mark
|
|
||||||
build:
|
|
||||||
timeout-minutes: 120
|
|
||||||
needs:
|
|
||||||
- ci-outpost-mark
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
matrix:
|
||||||
type:
|
type:
|
||||||
- proxy
|
- proxy
|
||||||
- ldap
|
- ldap
|
||||||
arch:
|
|
||||||
- 'linux/amd64'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v1.2.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -74,63 +51,25 @@ jobs:
|
|||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.HARBOR_USERNAME }}
|
||||||
run: |
|
run: |
|
||||||
python ./scripts/gh_env.py
|
python ./scripts/gh_do_set_branch.py
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v1
|
||||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: beryju.org
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ secrets.HARBOR_USERNAME }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.HARBOR_PASSWORD }}
|
||||||
- name: Building Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
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 }}
|
beryju.org/authentik/outpost-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchName }}
|
||||||
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}
|
beryju.org/authentik/outpost-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchName }}-${{ steps.ev.outputs.timestamp }}
|
||||||
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
|
beryju.org/authentik/outpost-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
|
||||||
file: ${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||||
platforms: ${{ matrix.arch }}
|
|
||||||
build-outpost-binary:
|
|
||||||
timeout-minutes: 120
|
|
||||||
needs:
|
|
||||||
- ci-outpost-mark
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
type:
|
|
||||||
- proxy
|
|
||||||
- ldap
|
|
||||||
goos: [linux]
|
|
||||||
goarch: [amd64, arm64]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-go@v2
|
|
||||||
with:
|
|
||||||
go-version: "^1.17"
|
|
||||||
- uses: actions/setup-node@v3.0.0
|
|
||||||
with:
|
|
||||||
node-version: '16'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: web/package-lock.json
|
|
||||||
- name: Build web
|
|
||||||
run: |
|
|
||||||
cd web
|
|
||||||
npm install
|
|
||||||
npm run build-proxy
|
|
||||||
- name: Build outpost
|
|
||||||
run: |
|
|
||||||
set -x
|
|
||||||
export GOOS=${{ matrix.goos }}
|
|
||||||
export GOARCH=${{ matrix.goarch }}
|
|
||||||
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
|
||||||
- uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
|
||||||
path: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
|
||||||
|
26
.github/workflows/ci-web.yml
vendored
26
.github/workflows/ci-web.yml
vendored
@ -14,8 +14,8 @@ jobs:
|
|||||||
lint-eslint:
|
lint-eslint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-node@v3.0.0
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -32,8 +32,8 @@ jobs:
|
|||||||
lint-prettier:
|
lint-prettier:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-node@v3.0.0
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -50,8 +50,8 @@ jobs:
|
|||||||
lint-lit-analyse:
|
lint-lit-analyse:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-node@v3.0.0
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -61,25 +61,19 @@ jobs:
|
|||||||
npm install
|
npm install
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-web
|
run: make gen-web
|
||||||
- name: lit-analyse
|
- name: prettier
|
||||||
run: |
|
run: |
|
||||||
cd web
|
cd web
|
||||||
npm run lit-analyse
|
npm run lit-analyse
|
||||||
ci-web-mark:
|
build:
|
||||||
needs:
|
needs:
|
||||||
- lint-eslint
|
- lint-eslint
|
||||||
- lint-prettier
|
- lint-prettier
|
||||||
- lint-lit-analyse
|
- lint-lit-analyse
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- uses: actions/checkout@v2
|
||||||
build:
|
- uses: actions/setup-node@v2
|
||||||
needs:
|
|
||||||
- ci-web-mark
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3.0.0
|
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
|
22
.github/workflows/ghcr-retention.yml
vendored
22
.github/workflows/ghcr-retention.yml
vendored
@ -1,22 +0,0 @@
|
|||||||
name: ghcr-retention
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 0 * * *' # every day at midnight
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
clean-ghcr:
|
|
||||||
name: Delete old unused container images
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Delete 'dev' containers older than a week
|
|
||||||
uses: sondrelg/container-retention-policy@v1
|
|
||||||
with:
|
|
||||||
image-names: dev-server,dev-ldap,dev-proxy
|
|
||||||
cut-off: One week ago UTC
|
|
||||||
account-type: org
|
|
||||||
org-name: goauthentik
|
|
||||||
untagged-only: false
|
|
||||||
token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
|
|
||||||
skip-tags: gh-next,gh-master
|
|
151
.github/workflows/release-publish.yml
vendored
151
.github/workflows/release-publish.yml
vendored
@ -3,13 +3,16 @@ name: authentik-on-release
|
|||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published, created]
|
types: [published, created]
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- version-*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Build
|
# Build
|
||||||
build-server:
|
build-server:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v1.2.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -30,25 +33,28 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
tags: |
|
tags: |
|
||||||
beryju/authentik:2022.3.3,
|
beryju/authentik:2021.8.5,
|
||||||
beryju/authentik:latest,
|
beryju/authentik:latest,
|
||||||
ghcr.io/goauthentik/server:2022.3.3,
|
ghcr.io/goauthentik/server:2021.8.5,
|
||||||
ghcr.io/goauthentik/server:latest
|
ghcr.io/goauthentik/server:latest
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
context: .
|
context: .
|
||||||
build-outpost:
|
- name: Building Docker Image (stable)
|
||||||
|
if: ${{ github.event_name == 'release' && !contains('2021.8.5', 'rc') }}
|
||||||
|
run: |
|
||||||
|
docker pull beryju/authentik:latest
|
||||||
|
docker tag beryju/authentik:latest beryju/authentik:stable
|
||||||
|
docker push beryju/authentik:stable
|
||||||
|
docker pull ghcr.io/goauthentik/server:latest
|
||||||
|
docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable
|
||||||
|
docker push ghcr.io/goauthentik/server:stable
|
||||||
|
build-proxy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
type:
|
|
||||||
- proxy
|
|
||||||
- ldap
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-go@v2
|
- uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.15"
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1.2.0
|
uses: docker/setup-qemu-action@v1.2.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -69,80 +75,97 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
tags: |
|
tags: |
|
||||||
beryju/authentik-${{ matrix.type }}:2022.3.3,
|
beryju/authentik-proxy:2021.8.5,
|
||||||
beryju/authentik-${{ matrix.type }}:latest,
|
beryju/authentik-proxy:latest,
|
||||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.3.3,
|
ghcr.io/goauthentik/proxy:2021.8.5,
|
||||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
ghcr.io/goauthentik/proxy:latest
|
||||||
file: ${{ matrix.type }}.Dockerfile
|
file: proxy.Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
build-outpost-binary:
|
- name: Building Docker Image (stable)
|
||||||
timeout-minutes: 120
|
if: ${{ github.event_name == 'release' && !contains('2021.8.5', 'rc') }}
|
||||||
|
run: |
|
||||||
|
docker pull beryju/authentik-proxy:latest
|
||||||
|
docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable
|
||||||
|
docker push beryju/authentik-proxy:stable
|
||||||
|
docker pull ghcr.io/goauthentik/proxy:latest
|
||||||
|
docker tag ghcr.io/goauthentik/proxy:latest ghcr.io/goauthentik/proxy:stable
|
||||||
|
docker push ghcr.io/goauthentik/proxy:stable
|
||||||
|
build-ldap:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
type:
|
|
||||||
- proxy
|
|
||||||
- ldap
|
|
||||||
goos: [linux, darwin]
|
|
||||||
goarch: [amd64, arm64]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-go@v2
|
- uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.15"
|
||||||
- uses: actions/setup-node@v3.0.0
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1.2.0
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
- name: Docker Login Registry
|
||||||
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
cache: 'npm'
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
cache-dependency-path: web/package-lock.json
|
- name: Login to GitHub Container Registry
|
||||||
- name: Build web
|
uses: docker/login-action@v1
|
||||||
run: |
|
|
||||||
cd web
|
|
||||||
npm install
|
|
||||||
npm run build-proxy
|
|
||||||
- name: Build outpost
|
|
||||||
run: |
|
|
||||||
set -x
|
|
||||||
export GOOS=${{ matrix.goos }}
|
|
||||||
export GOARCH=${{ matrix.goarch }}
|
|
||||||
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
|
||||||
- name: Upload binaries to release
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
registry: ghcr.io
|
||||||
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
username: ${{ github.repository_owner }}
|
||||||
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag: ${{ github.ref }}
|
- name: Building Docker Image
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
push: ${{ github.event_name == 'release' }}
|
||||||
|
tags: |
|
||||||
|
beryju/authentik-ldap:2021.8.5,
|
||||||
|
beryju/authentik-ldap:latest,
|
||||||
|
ghcr.io/goauthentik/ldap:2021.8.5,
|
||||||
|
ghcr.io/goauthentik/ldap:latest
|
||||||
|
file: ldap.Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
- name: Building Docker Image (stable)
|
||||||
|
if: ${{ github.event_name == 'release' && !contains('2021.8.5', 'rc') }}
|
||||||
|
run: |
|
||||||
|
docker pull beryju/authentik-ldap:latest
|
||||||
|
docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable
|
||||||
|
docker push beryju/authentik-ldap:stable
|
||||||
|
docker pull ghcr.io/goauthentik/ldap:latest
|
||||||
|
docker tag ghcr.io/goauthentik/ldap:latest ghcr.io/goauthentik/ldap:stable
|
||||||
|
docker push ghcr.io/goauthentik/ldap:stable
|
||||||
test-release:
|
test-release:
|
||||||
needs:
|
needs:
|
||||||
- build-server
|
- build-server
|
||||||
- build-outpost
|
- build-proxy
|
||||||
- build-outpost-binary
|
- build-ldap
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- name: Run test suite in final docker images
|
- name: Run test suite in final docker images
|
||||||
run: |
|
run: |
|
||||||
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
|
sudo apt-get install -y pwgen
|
||||||
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
|
echo "PG_PASS=$(pwgen 40 1)" >> .env
|
||||||
|
echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env
|
||||||
docker-compose pull -q
|
docker-compose pull -q
|
||||||
docker-compose up --no-start
|
docker-compose up --no-start
|
||||||
docker-compose start postgresql redis
|
docker-compose start postgresql redis
|
||||||
docker-compose run -u root server test
|
docker-compose run -u root server test
|
||||||
sentry-release:
|
sentry-release:
|
||||||
|
if: ${{ github.event_name == 'release' }}
|
||||||
needs:
|
needs:
|
||||||
- build-server
|
- test-release
|
||||||
- build-outpost
|
|
||||||
- build-outpost-binary
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- name: Get static files from docker image
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '16'
|
||||||
|
- name: Build web api client and web ui
|
||||||
run: |
|
run: |
|
||||||
docker pull ghcr.io/goauthentik/server:latest
|
export NODE_ENV=production
|
||||||
container=$(docker container create ghcr.io/goauthentik/server:latest)
|
cd web
|
||||||
docker cp ${container}:web/ .
|
npm i
|
||||||
|
npm run build
|
||||||
- name: Create a Sentry.io release
|
- name: Create a Sentry.io release
|
||||||
uses: getsentry/action-release@v1
|
uses: getsentry/action-release@v1
|
||||||
if: ${{ github.event_name == 'release' }}
|
if: ${{ github.event_name == 'release' }}
|
||||||
@ -152,7 +175,7 @@ jobs:
|
|||||||
SENTRY_PROJECT: authentik
|
SENTRY_PROJECT: authentik
|
||||||
SENTRY_URL: https://sentry.beryju.org
|
SENTRY_URL: https://sentry.beryju.org
|
||||||
with:
|
with:
|
||||||
version: authentik@2022.3.3
|
version: authentik@2021.8.5
|
||||||
environment: beryjuorg-prod
|
environment: beryjuorg-prod
|
||||||
sourcemaps: './web/dist'
|
sourcemaps: './web/dist'
|
||||||
url_prefix: '~/static/dist'
|
url_prefix: '~/static/dist'
|
||||||
|
16
.github/workflows/release-tag.yml
vendored
16
.github/workflows/release-tag.yml
vendored
@ -10,24 +10,24 @@ jobs:
|
|||||||
name: Create Release from Tag
|
name: Create Release from Tag
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
- name: Pre-release test
|
- name: Pre-release test
|
||||||
run: |
|
run: |
|
||||||
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
|
sudo apt-get install -y pwgen
|
||||||
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
|
echo "AUTHENTIK_TAG=latest" >> .env
|
||||||
docker buildx install
|
echo "PG_PASS=$(pwgen 40 1)" >> .env
|
||||||
|
echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env
|
||||||
|
docker-compose pull -q
|
||||||
docker build \
|
docker build \
|
||||||
--no-cache \
|
--no-cache \
|
||||||
-t testing:latest \
|
-t ghcr.io/goauthentik/server:latest \
|
||||||
-f Dockerfile .
|
-f Dockerfile .
|
||||||
echo "AUTHENTIK_IMAGE=testing" >> .env
|
|
||||||
echo "AUTHENTIK_TAG=latest" >> .env
|
|
||||||
docker-compose up --no-start
|
docker-compose up --no-start
|
||||||
docker-compose start postgresql redis
|
docker-compose start postgresql redis
|
||||||
docker-compose run -u root server test
|
docker-compose run -u root server test
|
||||||
- name: Extract version number
|
- name: Extract version number
|
||||||
id: get_version
|
id: get_version
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v4.1
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
56
.github/workflows/translation-compile.yml
vendored
56
.github/workflows/translation-compile.yml
vendored
@ -1,56 +0,0 @@
|
|||||||
name: authentik-backend-translate-compile
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master ]
|
|
||||||
paths:
|
|
||||||
- '/locale/'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '/locale/'
|
|
||||||
schedule:
|
|
||||||
- cron: "0 */2 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
POSTGRES_DB: authentik
|
|
||||||
POSTGRES_USER: authentik
|
|
||||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
compile:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-python@v3
|
|
||||||
- id: cache-poetry
|
|
||||||
uses: actions/cache@v2.1.7
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pypoetry/virtualenvs
|
|
||||||
key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }}
|
|
||||||
- name: prepare
|
|
||||||
env:
|
|
||||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y gettext
|
|
||||||
scripts/ci_prepare.sh
|
|
||||||
- name: run compile
|
|
||||||
run: poetry run ./manage.py compilemessages
|
|
||||||
- name: Create Pull Request
|
|
||||||
uses: peter-evans/create-pull-request@v3
|
|
||||||
id: cpr
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
branch: compile-backend-translation
|
|
||||||
commit-message: "core: compile backend translations"
|
|
||||||
title: "core: compile backend translations"
|
|
||||||
body: "core: compile backend translations"
|
|
||||||
delete-branch: true
|
|
||||||
signoff: true
|
|
||||||
- name: Enable Pull Request Automerge
|
|
||||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
|
||||||
uses: peter-evans/enable-pull-request-automerge@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
|
||||||
merge-method: squash
|
|
13
.github/workflows/web-api-publish.yml
vendored
13
.github/workflows/web-api-publish.yml
vendored
@ -8,9 +8,9 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v2
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v3.0.0
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
@ -30,19 +30,10 @@ jobs:
|
|||||||
npm i @goauthentik/api@$VERSION
|
npm i @goauthentik/api@$VERSION
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v3
|
uses: peter-evans/create-pull-request@v3
|
||||||
id: cpr
|
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
branch: update-web-api-client
|
branch: update-web-api-client
|
||||||
commit-message: "web: Update Web API Client version"
|
commit-message: "web: Update Web API Client version"
|
||||||
title: "web: Update Web API Client version"
|
title: "web: Update Web API Client version"
|
||||||
body: "web: Update Web API Client version"
|
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
signoff: true
|
signoff: true
|
||||||
- name: Enable Pull Request Automerge
|
|
||||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
|
||||||
uses: peter-evans/enable-pull-request-automerge@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
|
||||||
merge-method: squash
|
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -66,9 +66,7 @@ coverage.xml
|
|||||||
unittest.xml
|
unittest.xml
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
# Have to include binary mo files as they are annoying to compile at build time
|
*.mo
|
||||||
# since a full postgres and redis instance are required
|
|
||||||
# *.mo
|
|
||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
|
|
||||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -10,10 +10,7 @@
|
|||||||
"plex",
|
"plex",
|
||||||
"saml",
|
"saml",
|
||||||
"totp",
|
"totp",
|
||||||
"webauthn",
|
"webauthn"
|
||||||
"traefik",
|
|
||||||
"passwordless",
|
|
||||||
"kubernetes"
|
|
||||||
],
|
],
|
||||||
"python.linting.pylintEnabled": true,
|
"python.linting.pylintEnabled": true,
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
|
@ -31,7 +31,7 @@ Basically, don't be a dickhead. This is an open-source non-profit project, that
|
|||||||
|
|
||||||
## I don't want to read this whole thing I just have a question!!!
|
## I don't want to read this whole thing I just have a question!!!
|
||||||
|
|
||||||
Either [create a question on GitHub](https://github.com/goauthentik/authentik/issues/new?assignees=&labels=question&template=question.md&title=) or join [the Discord server](https://goauthentik.io/discord)
|
Either [create a question on GitHub](https://github.com/goauthentik/authentik/issues/new?assignees=&labels=question&template=question.md&title=) or join [the Discord server](https://discord.gg/jg33eMhnj6)
|
||||||
|
|
||||||
## What should I know before I get started?
|
## What should I know before I get started?
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ This section guides you through submitting a bug report for authentik. Following
|
|||||||
|
|
||||||
Whenever authentik encounters an error, it will be logged as an Event with the type `system_exception`. This event type has a button to directly open a pre-filled GitHub issue form.
|
Whenever authentik encounters an error, it will be logged as an Event with the type `system_exception`. This event type has a button to directly open a pre-filled GitHub issue form.
|
||||||
|
|
||||||
This form will have the full stack trace of the error that occurred and shouldn't contain any sensitive data.
|
This form will have the full stack trace of the error that ocurred and shouldn't contain any sensitive data.
|
||||||
|
|
||||||
### Suggesting Enhancements
|
### Suggesting Enhancements
|
||||||
|
|
||||||
@ -131,7 +131,7 @@ When you are creating an enhancement suggestion, please fill in [the template](h
|
|||||||
|
|
||||||
authentik can be run locally, all though depending on which part you want to work on, different pre-requisites are required.
|
authentik can be run locally, all though depending on which part you want to work on, different pre-requisites are required.
|
||||||
|
|
||||||
This is documented in the [developer docs](https://goauthentik.io/developer-docs/?utm_source=github)
|
This is documented in the [developer docs](https://goauthentik.io/developer-docs/)
|
||||||
|
|
||||||
### Pull Requests
|
### Pull Requests
|
||||||
|
|
||||||
|
99
Dockerfile
99
Dockerfile
@ -1,68 +1,91 @@
|
|||||||
# Stage 1: Build website
|
# Stage 1: Lock python dependencies
|
||||||
FROM --platform=${BUILDPLATFORM} docker.io/node:16 as website-builder
|
FROM python:3.9-slim-buster as locker
|
||||||
|
|
||||||
COPY ./website /work/website/
|
COPY ./Pipfile /app/
|
||||||
|
COPY ./Pipfile.lock /app/
|
||||||
|
|
||||||
|
WORKDIR /app/
|
||||||
|
|
||||||
|
RUN pip install pipenv && \
|
||||||
|
pipenv lock -r > requirements.txt && \
|
||||||
|
pipenv lock -r --dev-only > requirements-dev.txt
|
||||||
|
|
||||||
|
# Stage 2: Build website
|
||||||
|
FROM node as website-builder
|
||||||
|
|
||||||
|
COPY ./website /static/
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN cd /work/website && npm i && npm run build-docs-only
|
RUN cd /static && npm i && npm run build-docs-only
|
||||||
|
|
||||||
# Stage 2: Build webui
|
# Stage 3: Generate API Client
|
||||||
FROM --platform=${BUILDPLATFORM} docker.io/node:16 as web-builder
|
FROM openapitools/openapi-generator-cli as go-api-builder
|
||||||
|
|
||||||
COPY ./web /work/web/
|
COPY ./schema.yml /local/schema.yml
|
||||||
COPY ./website /work/website/
|
|
||||||
|
RUN docker-entrypoint.sh generate \
|
||||||
|
--git-host goauthentik.io \
|
||||||
|
--git-repo-id outpost \
|
||||||
|
--git-user-id api \
|
||||||
|
-i /local/schema.yml \
|
||||||
|
-g go \
|
||||||
|
-o /local/api \
|
||||||
|
--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true && \
|
||||||
|
rm -f /local/api/go.mod /local/api/go.sum
|
||||||
|
|
||||||
|
# Stage 4: Build webui
|
||||||
|
FROM node as web-builder
|
||||||
|
|
||||||
|
COPY ./web /static/
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN cd /work/web && npm i && npm run build
|
RUN cd /static && npm i && npm run build
|
||||||
|
|
||||||
# Stage 3: Build go proxy
|
# Stage 5: Build go proxy
|
||||||
FROM docker.io/golang:1.18.0-bullseye AS builder
|
FROM golang:1.17.0 AS builder
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
|
|
||||||
COPY --from=web-builder /work/web/robots.txt /work/web/robots.txt
|
COPY --from=web-builder /static/robots.txt /work/web/robots.txt
|
||||||
COPY --from=web-builder /work/web/security.txt /work/web/security.txt
|
COPY --from=web-builder /static/security.txt /work/web/security.txt
|
||||||
|
COPY --from=web-builder /static/dist/ /work/web/dist/
|
||||||
|
COPY --from=web-builder /static/authentik/ /work/web/authentik/
|
||||||
|
COPY --from=website-builder /static/help/ /work/website/help/
|
||||||
|
|
||||||
|
COPY --from=go-api-builder /local/api api
|
||||||
COPY ./cmd /work/cmd
|
COPY ./cmd /work/cmd
|
||||||
COPY ./web/static.go /work/web/static.go
|
COPY ./web/static.go /work/web/static.go
|
||||||
|
COPY ./website/static.go /work/website/static.go
|
||||||
COPY ./internal /work/internal
|
COPY ./internal /work/internal
|
||||||
COPY ./go.mod /work/go.mod
|
COPY ./go.mod /work/go.mod
|
||||||
COPY ./go.sum /work/go.sum
|
COPY ./go.sum /work/go.sum
|
||||||
|
|
||||||
RUN go build -o /work/authentik ./cmd/server/main.go
|
RUN go build -o /work/authentik ./cmd/server/main.go
|
||||||
|
|
||||||
# Stage 4: Run
|
# Stage 6: Run
|
||||||
FROM docker.io/python:3.10.3-slim-bullseye
|
FROM python:3.9-slim-buster
|
||||||
|
|
||||||
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.source https://github.com/goauthentik/authentik
|
|
||||||
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
COPY --from=locker /app/requirements.txt /
|
||||||
|
COPY --from=locker /app/requirements-dev.txt /
|
||||||
|
|
||||||
ARG GIT_BUILD_HASH
|
ARG GIT_BUILD_HASH
|
||||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||||
|
|
||||||
COPY ./pyproject.toml /
|
|
||||||
COPY ./poetry.lock /
|
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends curl ca-certificates gnupg git runit && \
|
||||||
curl ca-certificates gnupg git runit libpq-dev \
|
curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
|
||||||
postgresql-client build-essential libxmlsec1-dev \
|
echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
|
||||||
pkg-config libmaxminddb0 && \
|
apt-get update && \
|
||||||
pip install poetry && \
|
apt-get install -y --no-install-recommends libpq-dev postgresql-client build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
|
||||||
poetry config virtualenvs.create false && \
|
pip install -r /requirements.txt --no-cache-dir && \
|
||||||
poetry install --no-dev && \
|
|
||||||
rm -rf ~/.cache/pypoetry && \
|
|
||||||
apt-get remove --purge -y build-essential git && \
|
apt-get remove --purge -y build-essential git && \
|
||||||
apt-get autoremove --purge -y && \
|
apt-get autoremove --purge -y && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
||||||
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
|
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
|
||||||
mkdir -p /certs /media && \
|
mkdir /backups && \
|
||||||
mkdir -p /authentik/.ssh && \
|
chown authentik:authentik /backups
|
||||||
chown authentik:authentik /certs /media /authentik/.ssh
|
|
||||||
|
|
||||||
COPY ./authentik/ /authentik
|
COPY ./authentik/ /authentik
|
||||||
COPY ./pyproject.toml /
|
COPY ./pyproject.toml /
|
||||||
@ -71,16 +94,10 @@ COPY ./tests /tests
|
|||||||
COPY ./manage.py /
|
COPY ./manage.py /
|
||||||
COPY ./lifecycle/ /lifecycle
|
COPY ./lifecycle/ /lifecycle
|
||||||
COPY --from=builder /work/authentik /authentik-proxy
|
COPY --from=builder /work/authentik /authentik-proxy
|
||||||
COPY --from=web-builder /work/web/dist/ /web/dist/
|
|
||||||
COPY --from=web-builder /work/web/authentik/ /web/authentik/
|
|
||||||
COPY --from=website-builder /work/website/help/ /website/help/
|
|
||||||
|
|
||||||
USER authentik
|
USER authentik
|
||||||
|
|
||||||
ENV TMPDIR /dev/shm/
|
ENV TMPDIR /dev/shm/
|
||||||
ENV PYTHONUNBUFFERED 1
|
ENV PYTHONUBUFFERED 1
|
||||||
|
ENV prometheus_multiproc_dir /dev/shm/
|
||||||
ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle"
|
ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle"
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak", "healthcheck" ]
|
|
||||||
|
|
||||||
ENTRYPOINT [ "/lifecycle/ak" ]
|
ENTRYPOINT [ "/lifecycle/ak" ]
|
||||||
|
97
Makefile
97
Makefile
@ -4,46 +4,27 @@ UID = $(shell id -u)
|
|||||||
GID = $(shell id -g)
|
GID = $(shell id -g)
|
||||||
NPM_VERSION = $(shell python -m scripts.npm_version)
|
NPM_VERSION = $(shell python -m scripts.npm_version)
|
||||||
|
|
||||||
all: lint-fix lint test gen web
|
all: lint-fix lint test gen
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
coverage run manage.py test tests/integration
|
coverage run manage.py test -v 3 tests/integration
|
||||||
|
|
||||||
test-e2e-provider:
|
test-e2e:
|
||||||
coverage run manage.py test tests/e2e/test_provider*
|
coverage run manage.py test --failfast -v 3 tests/e2e
|
||||||
|
|
||||||
test-e2e-rest:
|
|
||||||
coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source*
|
|
||||||
|
|
||||||
test-go:
|
|
||||||
go test -timeout 0 -v -race -cover ./...
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
coverage run manage.py test authentik
|
coverage run manage.py test -v 3 authentik
|
||||||
coverage html
|
coverage html
|
||||||
coverage report
|
coverage report
|
||||||
|
|
||||||
lint-fix:
|
lint-fix:
|
||||||
isort authentik tests lifecycle
|
isort authentik tests lifecycle
|
||||||
black authentik tests lifecycle
|
black authentik tests lifecycle
|
||||||
codespell -I .github/codespell-words.txt -S 'web/src/locales/**' -w \
|
|
||||||
authentik \
|
|
||||||
internal \
|
|
||||||
cmd \
|
|
||||||
web/src \
|
|
||||||
website/src \
|
|
||||||
website/docs \
|
|
||||||
website/developer-docs
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
|
pyright authentik tests lifecycle
|
||||||
bandit -r authentik tests lifecycle -x node_modules
|
bandit -r authentik tests lifecycle -x node_modules
|
||||||
pylint authentik tests lifecycle
|
pylint authentik tests lifecycle
|
||||||
golangci-lint run -v
|
|
||||||
|
|
||||||
i18n-extract: i18n-extract-core web-extract
|
|
||||||
|
|
||||||
i18n-extract-core:
|
|
||||||
./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
|
|
||||||
|
|
||||||
gen-build:
|
gen-build:
|
||||||
./manage.py spectacular --file schema.yml
|
./manage.py spectacular --file schema.yml
|
||||||
@ -68,75 +49,23 @@ gen-web:
|
|||||||
\cp -rfv web-api/* web/node_modules/@goauthentik/api
|
\cp -rfv web-api/* web/node_modules/@goauthentik/api
|
||||||
|
|
||||||
gen-outpost:
|
gen-outpost:
|
||||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O config.yaml
|
|
||||||
mkdir -p templates
|
|
||||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O templates/README.mustache
|
|
||||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O templates/go.mod.mustache
|
|
||||||
docker run \
|
docker run \
|
||||||
--rm -v ${PWD}:/local \
|
--rm -v ${PWD}:/local \
|
||||||
--user ${UID}:${GID} \
|
--user ${UID}:${GID} \
|
||||||
openapitools/openapi-generator-cli:v5.2.1 generate \
|
openapitools/openapi-generator-cli generate \
|
||||||
|
--git-host goauthentik.io \
|
||||||
|
--git-repo-id outpost \
|
||||||
|
--git-user-id api \
|
||||||
-i /local/schema.yml \
|
-i /local/schema.yml \
|
||||||
-g go \
|
-g go \
|
||||||
-o /local/api \
|
-o /local/api \
|
||||||
-c /local/config.yaml
|
--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true,disallowAdditionalPropertiesIfNotPresent=false
|
||||||
go mod edit -replace goauthentik.io/api=./api
|
rm -f api/go.mod api/go.sum
|
||||||
rm -rf config.yaml ./templates/
|
|
||||||
|
|
||||||
gen: gen-build gen-clean gen-web
|
gen: gen-build gen-clean gen-web gen-outpost
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
python -m lifecycle.migrate
|
python -m lifecycle.migrate
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run -v cmd/server/main.go
|
go run -v cmd/server/main.go
|
||||||
|
|
||||||
web-watch:
|
|
||||||
cd web && npm run watch
|
|
||||||
|
|
||||||
web: web-lint-fix web-lint web-extract
|
|
||||||
|
|
||||||
web-lint-fix:
|
|
||||||
cd web && npm run prettier
|
|
||||||
|
|
||||||
web-lint:
|
|
||||||
cd web && npm run lint
|
|
||||||
cd web && npm run lit-analyse
|
|
||||||
|
|
||||||
web-extract:
|
|
||||||
cd web && npm run extract
|
|
||||||
|
|
||||||
# These targets are use by GitHub actions to allow usage of matrix
|
|
||||||
# which makes the YAML File a lot smaller
|
|
||||||
|
|
||||||
ci--meta-debug:
|
|
||||||
python -V
|
|
||||||
node --version
|
|
||||||
|
|
||||||
ci-pylint: ci--meta-debug
|
|
||||||
pylint authentik tests lifecycle
|
|
||||||
|
|
||||||
ci-black: ci--meta-debug
|
|
||||||
black --check authentik tests lifecycle
|
|
||||||
|
|
||||||
ci-isort: ci--meta-debug
|
|
||||||
isort --check authentik tests lifecycle
|
|
||||||
|
|
||||||
ci-bandit: ci--meta-debug
|
|
||||||
bandit -r authentik tests lifecycle
|
|
||||||
|
|
||||||
ci-pyright: ci--meta-debug
|
|
||||||
pyright e2e lifecycle
|
|
||||||
|
|
||||||
ci-pending-migrations: ci--meta-debug
|
|
||||||
./manage.py makemigrations --check
|
|
||||||
|
|
||||||
install:
|
|
||||||
poetry install
|
|
||||||
cd web && npm i
|
|
||||||
cd website && npm i
|
|
||||||
|
|
||||||
a: install
|
|
||||||
tmux \
|
|
||||||
new-session 'make run' \; \
|
|
||||||
split-window 'make web-watch'
|
|
||||||
|
66
Pipfile
Normal file
66
Pipfile
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
[[source]]
|
||||||
|
name = "pypi"
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
boto3 = "*"
|
||||||
|
celery = "*"
|
||||||
|
channels = "*"
|
||||||
|
channels-redis = "*"
|
||||||
|
dacite = "*"
|
||||||
|
defusedxml = "*"
|
||||||
|
django = "*"
|
||||||
|
django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' }
|
||||||
|
django-filter = "*"
|
||||||
|
django-guardian = "*"
|
||||||
|
django-model-utils = "*"
|
||||||
|
django-otp = "*"
|
||||||
|
django-prometheus = "*"
|
||||||
|
django-redis = "*"
|
||||||
|
django-storages = "*"
|
||||||
|
djangorestframework = "*"
|
||||||
|
djangorestframework-guardian = "*"
|
||||||
|
docker = "*"
|
||||||
|
drf-spectacular = "*"
|
||||||
|
facebook-sdk = "*"
|
||||||
|
geoip2 = "*"
|
||||||
|
gunicorn = "*"
|
||||||
|
kubernetes = "*"
|
||||||
|
ldap3 = "*"
|
||||||
|
lxml = ">=4.6.3"
|
||||||
|
packaging = "*"
|
||||||
|
psycopg2-binary = "*"
|
||||||
|
pycryptodome = "*"
|
||||||
|
pyjwt = "*"
|
||||||
|
pyyaml = "*"
|
||||||
|
requests-oauthlib = "*"
|
||||||
|
sentry-sdk = "*"
|
||||||
|
service_identity = "*"
|
||||||
|
structlog = "*"
|
||||||
|
swagger-spec-validator = "*"
|
||||||
|
twisted = "==21.7.0"
|
||||||
|
urllib3 = {extras = ["secure"],version = "*"}
|
||||||
|
uvicorn = {extras = ["standard"],version = "*"}
|
||||||
|
webauthn = "*"
|
||||||
|
xmlsec = "*"
|
||||||
|
duo-client = "*"
|
||||||
|
ua-parser = "*"
|
||||||
|
deepmerge = "*"
|
||||||
|
colorama = "*"
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.9"
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
bandit = "*"
|
||||||
|
black = "==21.5b1"
|
||||||
|
bump2version = "*"
|
||||||
|
colorama = "*"
|
||||||
|
coverage = "*"
|
||||||
|
pylint = "*"
|
||||||
|
pylint-django = "*"
|
||||||
|
pytest = "*"
|
||||||
|
pytest-django = "*"
|
||||||
|
selenium = "*"
|
||||||
|
requests-mock = "*"
|
1879
Pipfile.lock
generated
Normal file
1879
Pipfile.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
README.md
28
README.md
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[](https://goauthentik.io/discord)
|
[](https://discord.gg/jg33eMhnj6)
|
||||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-main.yml)
|
[](https://github.com/goauthentik/authentik/actions/workflows/ci-main.yml)
|
||||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml)
|
[](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml)
|
||||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml)
|
[](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml)
|
||||||
@ -20,9 +20,9 @@ authentik is an open-source Identity Provider focused on flexibility and versati
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/?utm_source=github)
|
For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/)
|
||||||
|
|
||||||
For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/?utm_source=github)
|
For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@ -33,28 +33,8 @@ Light | Dark
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
See [Development Documentation](https://goauthentik.io/developer-docs/?utm_source=github)
|
See [Development Documentation](https://goauthentik.io/developer-docs/)
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md)
|
See [SECURITY.md](SECURITY.md)
|
||||||
|
|
||||||
## Sponsors
|
|
||||||
|
|
||||||
This project is proudly sponsored by:
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=goauthentik.io">
|
|
||||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
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 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ---------- | ------------------ |
|
| ---------- | ------------------ |
|
||||||
| 2022.2.x | :white_check_mark: |
|
| 2021.7.x | :white_check_mark: |
|
||||||
| 2022.3.x | :white_check_mark: |
|
| 2021.8.x | :white_check_mark: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
@ -1,19 +1,3 @@
|
|||||||
"""authentik"""
|
"""authentik"""
|
||||||
from os import environ
|
__version__ = "2021.8.5"
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
__version__ = "2022.3.3"
|
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
def get_build_hash(fallback: Optional[str] = None) -> str:
|
|
||||||
"""Get build hash"""
|
|
||||||
return environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
|
|
||||||
|
|
||||||
|
|
||||||
def get_full_version() -> str:
|
|
||||||
"""Get full version, with build hash appended"""
|
|
||||||
version = __version__
|
|
||||||
if (build_hash := get_build_hash()) != "":
|
|
||||||
version += "." + build_hash
|
|
||||||
return version
|
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
"""authentik administration metrics"""
|
"""authentik administration metrics"""
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db.models import Count, ExpressionWrapper, F
|
||||||
|
from django.db.models.fields import DurationField
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
|
from django.utils.timezone import now
|
||||||
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 rest_framework.fields import IntegerField, SerializerMethodField
|
from rest_framework.fields import IntegerField, 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
|
||||||
@ -8,7 +15,31 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.events.models import EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
|
||||||
|
|
||||||
|
def get_events_per_1h(**filter_kwargs) -> list[dict[str, int]]:
|
||||||
|
"""Get event count by hour in the last day, fill with zeros"""
|
||||||
|
date_from = now() - timedelta(days=1)
|
||||||
|
result = (
|
||||||
|
Event.objects.filter(created__gte=date_from, **filter_kwargs)
|
||||||
|
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
||||||
|
.annotate(age_hours=ExtractHour("age"))
|
||||||
|
.values("age_hours")
|
||||||
|
.annotate(count=Count("pk"))
|
||||||
|
.order_by("age_hours")
|
||||||
|
)
|
||||||
|
data = Counter({int(d["age_hours"]): d["count"] for d in result})
|
||||||
|
results = []
|
||||||
|
_now = now()
|
||||||
|
for hour in range(0, -24, -1):
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
|
||||||
|
"y_cord": data[hour * -1],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
class CoordinateSerializer(PassiveSerializer):
|
class CoordinateSerializer(PassiveSerializer):
|
||||||
@ -27,22 +58,12 @@ class LoginMetricsSerializer(PassiveSerializer):
|
|||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_per_1h(self, _):
|
def get_logins_per_1h(self, _):
|
||||||
"""Get successful logins per hour for the last 24 hours"""
|
"""Get successful logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
return get_events_per_1h(action=EventAction.LOGIN)
|
||||||
return (
|
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
|
||||||
.filter(action=EventAction.LOGIN)
|
|
||||||
.get_events_per_hour()
|
|
||||||
)
|
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed_per_1h(self, _):
|
def get_logins_failed_per_1h(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
return get_events_per_1h(action=EventAction.LOGIN_FAILED)
|
||||||
return (
|
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
|
||||||
.filter(action=EventAction.LOGIN_FAILED)
|
|
||||||
.get_events_per_hour()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AdministrationMetricsViewSet(APIView):
|
class AdministrationMetricsViewSet(APIView):
|
||||||
@ -54,5 +75,4 @@ class AdministrationMetricsViewSet(APIView):
|
|||||||
def get(self, request: Request) -> Response:
|
def get(self, request: Request) -> Response:
|
||||||
"""Login Metrics per 1h"""
|
"""Login Metrics per 1h"""
|
||||||
serializer = LoginMetricsSerializer(True)
|
serializer = LoginMetricsSerializer(True)
|
||||||
serializer.context["user"] = request.user
|
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
@ -84,9 +84,9 @@ class SystemSerializer(PassiveSerializer):
|
|||||||
return now()
|
return now()
|
||||||
|
|
||||||
def get_embedded_outpost_host(self, request: Request) -> str:
|
def get_embedded_outpost_host(self, request: Request) -> str:
|
||||||
"""Get the FQDN configured on the embedded outpost"""
|
"""Get the FQDN configured on the embeddded outpost"""
|
||||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||||
if not outposts.exists(): # pragma: no cover
|
if not outposts.exists():
|
||||||
return ""
|
return ""
|
||||||
return outposts.first().config.authentik_host
|
return outposts.first().config.authentik_host
|
||||||
|
|
||||||
|
@ -12,13 +12,10 @@ from rest_framework.permissions import IsAdminUser
|
|||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ViewSet
|
from rest_framework.viewsets import ViewSet
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
|
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class TaskSerializer(PassiveSerializer):
|
class TaskSerializer(PassiveSerializer):
|
||||||
"""Serialize TaskInfo and TaskResult"""
|
"""Serialize TaskInfo and TaskResult"""
|
||||||
@ -39,7 +36,7 @@ class TaskSerializer(PassiveSerializer):
|
|||||||
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)
|
||||||
except AttributeError: # pragma: no cover
|
except AttributeError:
|
||||||
if isinstance(self.instance, list):
|
if isinstance(self.instance, list):
|
||||||
for inst in self.instance:
|
for inst in self.instance:
|
||||||
inst.delete()
|
inst.delete()
|
||||||
@ -92,15 +89,13 @@ class TaskViewSet(ViewSet):
|
|||||||
try:
|
try:
|
||||||
task_module = import_module(task.task_call_module)
|
task_module = import_module(task.task_call_module)
|
||||||
task_func = getattr(task_module, task.task_call_func)
|
task_func = getattr(task_module, task.task_call_func)
|
||||||
LOGGER.debug("Running task", task=task_func)
|
|
||||||
task_func.delay(*task.task_call_args, **task.task_call_kwargs)
|
task_func.delay(*task.task_call_args, **task.task_call_kwargs)
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
_("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}),
|
_("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}),
|
||||||
)
|
)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
except (ImportError, AttributeError): # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
LOGGER.warning("Failed to run task, remove state", task=task)
|
|
||||||
# if we get an import error, the module path has probably changed
|
# if we get an import error, the module path has probably changed
|
||||||
task.delete()
|
task.delete()
|
||||||
return Response(status=500)
|
return Response(status=500)
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
"""authentik administration overview"""
|
"""authentik administration overview"""
|
||||||
|
from os import environ
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from packaging.version import parse
|
from packaging.version import parse
|
||||||
@ -8,7 +10,7 @@ from rest_framework.request import Request
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from authentik import __version__, get_build_hash
|
from authentik import ENV_GIT_HASH_KEY, __version__
|
||||||
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
|
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
|
||||||
@ -23,7 +25,7 @@ class VersionSerializer(PassiveSerializer):
|
|||||||
|
|
||||||
def get_build_hash(self, _) -> str:
|
def get_build_hash(self, _) -> str:
|
||||||
"""Get build hash, if version is not latest or released"""
|
"""Get build hash, if version is not latest or released"""
|
||||||
return get_build_hash()
|
return environ.get(ENV_GIT_HASH_KEY, "")
|
||||||
|
|
||||||
def get_version_current(self, _) -> str:
|
def get_version_current(self, _) -> str:
|
||||||
"""Get current version"""
|
"""Get current version"""
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""authentik administration overview"""
|
"""authentik administration overview"""
|
||||||
from django.conf import settings
|
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
from prometheus_client import Gauge
|
from prometheus_client import Gauge
|
||||||
from rest_framework.fields import IntegerField
|
from rest_framework.fields import IntegerField
|
||||||
@ -22,7 +21,4 @@ class WorkerView(APIView):
|
|||||||
def get(self, request: Request) -> Response:
|
def get(self, request: Request) -> Response:
|
||||||
"""Get currently connected worker count."""
|
"""Get currently connected worker count."""
|
||||||
count = len(CELERY_APP.control.ping(timeout=0.5))
|
count = len(CELERY_APP.control.ping(timeout=0.5))
|
||||||
# In debug we run with `CELERY_TASK_ALWAYS_EAGER`, so tasks are ran on the main process
|
|
||||||
if settings.DEBUG: # pragma: no cover
|
|
||||||
count += 1
|
|
||||||
return Response({"count": count})
|
return Response({"count": count})
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""authentik admin app config"""
|
"""authentik admin app config"""
|
||||||
from importlib import import_module
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
@ -10,9 +8,3 @@ class AuthentikAdminConfig(AppConfig):
|
|||||||
name = "authentik.admin"
|
name = "authentik.admin"
|
||||||
label = "authentik_admin"
|
label = "authentik_admin"
|
||||||
verbose_name = "authentik Admin"
|
verbose_name = "authentik Admin"
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
from authentik.admin.tasks import clear_update_notifications
|
|
||||||
|
|
||||||
clear_update_notifications.delay()
|
|
||||||
import_module("authentik.admin.signals")
|
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
"""admin signals"""
|
|
||||||
from django.dispatch import receiver
|
|
||||||
|
|
||||||
from authentik.admin.api.tasks import TaskInfo
|
|
||||||
from authentik.admin.api.workers import GAUGE_WORKERS
|
|
||||||
from authentik.root.celery import CELERY_APP
|
|
||||||
from authentik.root.monitoring import monitoring_set
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_workers(sender, **kwargs):
|
|
||||||
"""Set worker gauge"""
|
|
||||||
count = len(CELERY_APP.control.ping(timeout=0.5))
|
|
||||||
GAUGE_WORKERS.set(count)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_tasks(sender, **kwargs):
|
|
||||||
"""Set task gauges"""
|
|
||||||
for task in TaskInfo.all().values():
|
|
||||||
task.set_prom_metrics()
|
|
@ -1,23 +1,17 @@
|
|||||||
"""authentik admin tasks"""
|
"""authentik admin tasks"""
|
||||||
import re
|
import re
|
||||||
|
from os import environ
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from packaging.version import parse
|
from packaging.version import parse
|
||||||
from prometheus_client import Info
|
from prometheus_client import Info
|
||||||
from requests import RequestException
|
from requests import RequestException, get
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik import __version__, get_build_hash
|
from authentik import ENV_GIT_HASH_KEY, __version__
|
||||||
from authentik.events.models import Event, EventAction, Notification
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.events.monitored_tasks import (
|
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||||
MonitoredTask,
|
|
||||||
TaskResult,
|
|
||||||
TaskResultStatus,
|
|
||||||
prefill_task,
|
|
||||||
)
|
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
from authentik.lib.utils.http import get_http_session
|
|
||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
@ -26,7 +20,6 @@ VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
|
|||||||
# Chop of the first ^ because we want to search the entire string
|
# Chop of the first ^ because we want to search the entire string
|
||||||
URL_FINDER = URLValidator.regex.pattern[1:]
|
URL_FINDER = URLValidator.regex.pattern[1:]
|
||||||
PROM_INFO = Info("authentik_version", "Currently running authentik version")
|
PROM_INFO = Info("authentik_version", "Currently running authentik version")
|
||||||
LOCAL_VERSION = parse(__version__)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_prom_info():
|
def _set_prom_info():
|
||||||
@ -35,38 +28,20 @@ def _set_prom_info():
|
|||||||
{
|
{
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"latest": cache.get(VERSION_CACHE_KEY, ""),
|
"latest": cache.get(VERSION_CACHE_KEY, ""),
|
||||||
"build_hash": get_build_hash(),
|
"build_hash": environ.get(ENV_GIT_HASH_KEY, ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task()
|
|
||||||
def clear_update_notifications():
|
|
||||||
"""Clear update notifications on startup if the notification was for the version
|
|
||||||
we're running now."""
|
|
||||||
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
|
|
||||||
if "new_version" not in notification.event.context:
|
|
||||||
continue
|
|
||||||
notification_version = notification.event.context["new_version"]
|
|
||||||
if LOCAL_VERSION >= parse(notification_version):
|
|
||||||
notification.delete()
|
|
||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||||
@prefill_task
|
|
||||||
def update_latest_version(self: MonitoredTask):
|
def update_latest_version(self: MonitoredTask):
|
||||||
"""Update latest version info"""
|
"""Update latest version info"""
|
||||||
if CONFIG.y_bool("disable_update_check"):
|
|
||||||
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
|
|
||||||
self.set_status(TaskResult(TaskResultStatus.WARNING, messages=["Version check disabled."]))
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
response = get_http_session().get(
|
response = get("https://api.github.com/repos/goauthentik/authentik/releases/latest")
|
||||||
"https://version.goauthentik.io/version.json",
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
upstream_version = data.get("stable", {}).get("version")
|
tag_name = data.get("tag_name")
|
||||||
|
upstream_version = tag_name.split("/")[1]
|
||||||
cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT)
|
cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT)
|
||||||
self.set_status(
|
self.set_status(
|
||||||
TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"])
|
TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"])
|
||||||
@ -74,7 +49,8 @@ def update_latest_version(self: MonitoredTask):
|
|||||||
_set_prom_info()
|
_set_prom_info()
|
||||||
# Check if upstream version is newer than what we're running,
|
# Check if upstream version is newer than what we're running,
|
||||||
# and if no event exists yet, create one.
|
# and if no event exists yet, create one.
|
||||||
if LOCAL_VERSION < parse(upstream_version):
|
local_version = parse(__version__)
|
||||||
|
if local_version < parse(upstream_version):
|
||||||
# Event has already been created, don't create duplicate
|
# Event has already been created, don't create duplicate
|
||||||
if Event.objects.filter(
|
if Event.objects.filter(
|
||||||
action=EventAction.UPDATE_AVAILABLE,
|
action=EventAction.UPDATE_AVAILABLE,
|
||||||
@ -82,7 +58,7 @@ def update_latest_version(self: MonitoredTask):
|
|||||||
).exists():
|
).exists():
|
||||||
return
|
return
|
||||||
event_dict = {"new_version": upstream_version}
|
event_dict = {"new_version": upstream_version}
|
||||||
if match := re.search(URL_FINDER, data.get("stable", {}).get("changelog", "")):
|
if match := re.search(URL_FINDER, data.get("body", "")):
|
||||||
event_dict["message"] = f"Changelog: {match.group()}"
|
event_dict["message"] = f"Changelog: {match.group()}"
|
||||||
Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save()
|
Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save()
|
||||||
except (RequestException, IndexError) as exc:
|
except (RequestException, IndexError) as exc:
|
||||||
|
@ -8,7 +8,6 @@ from authentik import __version__
|
|||||||
from authentik.core.models import Group, User
|
from authentik.core.models import Group, User
|
||||||
from authentik.core.tasks import clean_expired_models
|
from authentik.core.tasks import clean_expired_models
|
||||||
from authentik.events.monitored_tasks import TaskResultStatus
|
from authentik.events.monitored_tasks import TaskResultStatus
|
||||||
from authentik.managed.tasks import managed_reconcile
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdminAPI(TestCase):
|
class TestAdminAPI(TestCase):
|
||||||
@ -95,7 +94,5 @@ class TestAdminAPI(TestCase):
|
|||||||
|
|
||||||
def test_system(self):
|
def test_system(self):
|
||||||
"""Test system API"""
|
"""Test system API"""
|
||||||
# pyright: reportGeneralTypeIssues=false
|
|
||||||
managed_reconcile() # pylint: disable=no-value-for-parameter
|
|
||||||
response = self.client.get(reverse("authentik_api:admin_system"))
|
response = self.client.get(reverse("authentik_api:admin_system"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -1,83 +1,81 @@
|
|||||||
"""test admin tasks"""
|
"""test admin tasks"""
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from requests_mock import Mocker
|
from requests.exceptions import RequestException
|
||||||
|
|
||||||
from authentik.admin.tasks import (
|
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
|
||||||
VERSION_CACHE_KEY,
|
|
||||||
clear_update_notifications,
|
|
||||||
update_latest_version,
|
|
||||||
)
|
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
|
|
||||||
RESPONSE_VALID = {
|
|
||||||
"$schema": "https://version.goauthentik.io/schema.json",
|
@dataclass
|
||||||
"stable": {
|
class MockResponse:
|
||||||
"version": "99999999.9999999",
|
"""Mock class to emulate the methods of requests's Response we need"""
|
||||||
"changelog": "See https://goauthentik.io/test",
|
|
||||||
"reason": "bugfix",
|
status_code: int
|
||||||
},
|
response: str
|
||||||
}
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
"""Get json parsed response"""
|
||||||
|
return json.loads(self.response)
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
"""raise RequestException if status code is 400 or more"""
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise RequestException
|
||||||
|
|
||||||
|
|
||||||
|
REQUEST_MOCK_VALID = Mock(
|
||||||
|
return_value=MockResponse(
|
||||||
|
200,
|
||||||
|
"""{
|
||||||
|
"tag_name": "version/99999999.9999999",
|
||||||
|
"body": "https://goauthentik.io/test"
|
||||||
|
}""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUEST_MOCK_INVALID = Mock(return_value=MockResponse(400, "{}"))
|
||||||
|
|
||||||
|
|
||||||
class TestAdminTasks(TestCase):
|
class TestAdminTasks(TestCase):
|
||||||
"""test admin tasks"""
|
"""test admin tasks"""
|
||||||
|
|
||||||
|
@patch("authentik.admin.tasks.get", REQUEST_MOCK_VALID)
|
||||||
def test_version_valid_response(self):
|
def test_version_valid_response(self):
|
||||||
"""Test Update checker with valid response"""
|
"""Test Update checker with valid response"""
|
||||||
with Mocker() as mocker:
|
update_latest_version.delay().get()
|
||||||
mocker.get("https://version.goauthentik.io/version.json", json=RESPONSE_VALID)
|
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
|
||||||
update_latest_version.delay().get()
|
self.assertTrue(
|
||||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
|
Event.objects.filter(
|
||||||
self.assertTrue(
|
action=EventAction.UPDATE_AVAILABLE,
|
||||||
|
context__new_version="99999999.9999999",
|
||||||
|
context__message="Changelog: https://goauthentik.io/test",
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
# test that a consecutive check doesn't create a duplicate event
|
||||||
|
update_latest_version.delay().get()
|
||||||
|
self.assertEqual(
|
||||||
|
len(
|
||||||
Event.objects.filter(
|
Event.objects.filter(
|
||||||
action=EventAction.UPDATE_AVAILABLE,
|
action=EventAction.UPDATE_AVAILABLE,
|
||||||
context__new_version="99999999.9999999",
|
context__new_version="99999999.9999999",
|
||||||
context__message="Changelog: https://goauthentik.io/test",
|
context__message="Changelog: https://goauthentik.io/test",
|
||||||
).exists()
|
)
|
||||||
)
|
),
|
||||||
# test that a consecutive check doesn't create a duplicate event
|
1,
|
||||||
update_latest_version.delay().get()
|
)
|
||||||
self.assertEqual(
|
|
||||||
len(
|
|
||||||
Event.objects.filter(
|
|
||||||
action=EventAction.UPDATE_AVAILABLE,
|
|
||||||
context__new_version="99999999.9999999",
|
|
||||||
context__message="Changelog: https://goauthentik.io/test",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
@patch("authentik.admin.tasks.get", REQUEST_MOCK_INVALID)
|
||||||
def test_version_error(self):
|
def test_version_error(self):
|
||||||
"""Test Update checker with invalid response"""
|
"""Test Update checker with invalid response"""
|
||||||
with Mocker() as mocker:
|
update_latest_version.delay().get()
|
||||||
mocker.get("https://version.goauthentik.io/version.json", status_code=400)
|
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
|
||||||
update_latest_version.delay().get()
|
|
||||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
|
|
||||||
self.assertFalse(
|
|
||||||
Event.objects.filter(
|
|
||||||
action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
|
|
||||||
).exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_version_disabled(self):
|
|
||||||
"""Test Update checker while its disabled"""
|
|
||||||
with CONFIG.patch("disable_update_check", True):
|
|
||||||
update_latest_version.delay().get()
|
|
||||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
|
|
||||||
|
|
||||||
def test_clear_update_notifications(self):
|
|
||||||
"""Test clear of previous notification"""
|
|
||||||
Event.objects.create(
|
|
||||||
action=EventAction.UPDATE_AVAILABLE, context={"new_version": "99999999.9999999.9999999"}
|
|
||||||
)
|
|
||||||
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"})
|
|
||||||
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={})
|
|
||||||
clear_update_notifications()
|
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
Event.objects.filter(
|
Event.objects.filter(
|
||||||
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1"
|
action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
|
||||||
).exists()
|
).exists()
|
||||||
)
|
)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"""API Authentication"""
|
"""API Authentication"""
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from binascii import Error
|
from binascii import Error
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from rest_framework.authentication import BaseAuthentication, get_authorization_header
|
from rest_framework.authentication import BaseAuthentication, get_authorization_header
|
||||||
@ -9,7 +9,6 @@ from rest_framework.exceptions import AuthenticationFailed
|
|||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.middleware import KEY_AUTH_VIA, LOCAL
|
|
||||||
from authentik.core.models import Token, TokenIntents, User
|
from authentik.core.models import Token, TokenIntents, User
|
||||||
from authentik.outposts.models import Outpost
|
from authentik.outposts.models import Outpost
|
||||||
|
|
||||||
@ -41,12 +40,11 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
|
|||||||
raise AuthenticationFailed("Malformed header")
|
raise AuthenticationFailed("Malformed header")
|
||||||
tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
|
tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
|
||||||
if not tokens.exists():
|
if not tokens.exists():
|
||||||
|
LOGGER.info("Authenticating via secret_key")
|
||||||
user = token_secret_key(password)
|
user = token_secret_key(password)
|
||||||
if not user:
|
if not user:
|
||||||
raise AuthenticationFailed("Token invalid/expired")
|
raise AuthenticationFailed("Token invalid/expired")
|
||||||
return user
|
return user
|
||||||
if hasattr(LOCAL, "authentik"):
|
|
||||||
LOCAL.authentik[KEY_AUTH_VIA] = "api_token"
|
|
||||||
return tokens.first().user
|
return tokens.first().user
|
||||||
|
|
||||||
|
|
||||||
@ -60,8 +58,6 @@ def token_secret_key(value: str) -> Optional[User]:
|
|||||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||||
if not outposts:
|
if not outposts:
|
||||||
return None
|
return None
|
||||||
if hasattr(LOCAL, "authentik"):
|
|
||||||
LOCAL.authentik[KEY_AUTH_VIA] = "secret_key"
|
|
||||||
outpost = outposts.first()
|
outpost = outposts.first()
|
||||||
return outpost.user
|
return outpost.user
|
||||||
|
|
||||||
@ -69,7 +65,7 @@ def token_secret_key(value: str) -> Optional[User]:
|
|||||||
class TokenAuthentication(BaseAuthentication):
|
class TokenAuthentication(BaseAuthentication):
|
||||||
"""Token-based authentication using HTTP Bearer authentication"""
|
"""Token-based authentication using HTTP Bearer authentication"""
|
||||||
|
|
||||||
def authenticate(self, request: Request) -> tuple[User, Any] | None:
|
def authenticate(self, request: Request) -> Union[tuple[User, Any], None]:
|
||||||
"""Token-based authentication using HTTP Bearer authentication"""
|
"""Token-based authentication using HTTP Bearer authentication"""
|
||||||
auth = get_authorization_header(request)
|
auth = get_authorization_header(request)
|
||||||
|
|
||||||
|
@ -33,12 +33,3 @@ class OwnerPermissions(BasePermission):
|
|||||||
if owner != request.user:
|
if owner != request.user:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class OwnerSuperuserPermissions(OwnerPermissions):
|
|
||||||
"""Similar to OwnerPermissions, except always allow access for superusers"""
|
|
||||||
|
|
||||||
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
|
|
||||||
if request.user.is_superuser:
|
|
||||||
return True
|
|
||||||
return super().has_object_permission(request, view, obj)
|
|
||||||
|
@ -5,9 +5,6 @@ from typing import Callable, Optional
|
|||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None):
|
def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None):
|
||||||
@ -21,12 +18,10 @@ def permission_required(perm: Optional[str] = None, other_perms: Optional[list[s
|
|||||||
if perm:
|
if perm:
|
||||||
obj = self.get_object()
|
obj = self.get_object()
|
||||||
if not request.user.has_perm(perm, obj):
|
if not request.user.has_perm(perm, obj):
|
||||||
LOGGER.debug("denying access for object", user=request.user, perm=perm, obj=obj)
|
|
||||||
return self.permission_denied(request)
|
return self.permission_denied(request)
|
||||||
if other_perms:
|
if other_perms:
|
||||||
for other_perm in other_perms:
|
for other_perm in other_perms:
|
||||||
if not request.user.has_perm(other_perm):
|
if not request.user.has_perm(other_perm):
|
||||||
LOGGER.debug("denying access for other", user=request.user, perm=perm)
|
|
||||||
return self.permission_denied(request)
|
return self.permission_denied(request)
|
||||||
return func(self, request, *args, **kwargs)
|
return func(self, request, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ from drf_spectacular.types import OpenApiTypes
|
|||||||
|
|
||||||
|
|
||||||
def build_standard_type(obj, **kwargs):
|
def build_standard_type(obj, **kwargs):
|
||||||
"""Build a basic type with optional add owns."""
|
"""Build a basic type with optional add ons."""
|
||||||
schema = build_basic_type(obj)
|
schema = build_basic_type(obj)
|
||||||
schema.update(kwargs)
|
schema.update(kwargs)
|
||||||
return schema
|
return schema
|
||||||
@ -31,7 +31,7 @@ VALIDATION_ERROR = build_object_type(
|
|||||||
"non_field_errors": build_array_type(build_standard_type(OpenApiTypes.STR)),
|
"non_field_errors": build_array_type(build_standard_type(OpenApiTypes.STR)),
|
||||||
"code": build_standard_type(OpenApiTypes.STR),
|
"code": build_standard_type(OpenApiTypes.STR),
|
||||||
},
|
},
|
||||||
required=[],
|
required=["detail"],
|
||||||
additionalProperties={},
|
additionalProperties={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@ function getCookie(name) {
|
|||||||
window.addEventListener('DOMContentLoaded', (event) => {
|
window.addEventListener('DOMContentLoaded', (event) => {
|
||||||
const rapidocEl = document.querySelector('rapi-doc');
|
const rapidocEl = document.querySelector('rapi-doc');
|
||||||
rapidocEl.addEventListener('before-try', (e) => {
|
rapidocEl.addEventListener('before-try', (e) => {
|
||||||
e.detail.request.headers.append('X-authentik-CSRF', getCookie("authentik_csrf"));
|
e.detail.request.headers.append('X-CSRFToken', getCookie("authentik_csrf"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -4,5 +4,7 @@ from django.urls import include, path
|
|||||||
from authentik.api.v3.urls import urlpatterns as v3_urls
|
from authentik.api.v3.urls import urlpatterns as v3_urls
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# Remove in 2022.1
|
||||||
|
path("v2beta/", include(v3_urls)),
|
||||||
path("v3/", include(v3_urls)),
|
path("v3/", include(v3_urls)),
|
||||||
]
|
]
|
||||||
|
@ -1,17 +1,11 @@
|
|||||||
"""core Configs API"""
|
"""core Configs API"""
|
||||||
from os import path
|
from os import environ, path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework.fields import (
|
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
|
||||||
BooleanField,
|
from rest_framework.fields import BooleanField, CharField, ChoiceField, IntegerField, ListField
|
||||||
CharField,
|
|
||||||
ChoiceField,
|
|
||||||
FloatField,
|
|
||||||
IntegerField,
|
|
||||||
ListField,
|
|
||||||
)
|
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -27,21 +21,16 @@ class Capabilities(models.TextChoices):
|
|||||||
|
|
||||||
CAN_SAVE_MEDIA = "can_save_media"
|
CAN_SAVE_MEDIA = "can_save_media"
|
||||||
CAN_GEO_IP = "can_geo_ip"
|
CAN_GEO_IP = "can_geo_ip"
|
||||||
|
CAN_BACKUP = "can_backup"
|
||||||
|
|
||||||
class ErrorReportingConfigSerializer(PassiveSerializer):
|
|
||||||
"""Config for error reporting"""
|
|
||||||
|
|
||||||
enabled = BooleanField(read_only=True)
|
|
||||||
environment = CharField(read_only=True)
|
|
||||||
send_pii = BooleanField(read_only=True)
|
|
||||||
traces_sample_rate = FloatField(read_only=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigSerializer(PassiveSerializer):
|
class ConfigSerializer(PassiveSerializer):
|
||||||
"""Serialize authentik Config into DRF Object"""
|
"""Serialize authentik Config into DRF Object"""
|
||||||
|
|
||||||
error_reporting = ErrorReportingConfigSerializer(required=True)
|
error_reporting_enabled = BooleanField(read_only=True)
|
||||||
|
error_reporting_environment = CharField(read_only=True)
|
||||||
|
error_reporting_send_pii = BooleanField(read_only=True)
|
||||||
|
|
||||||
capabilities = ListField(child=ChoiceField(choices=Capabilities.choices))
|
capabilities = ListField(child=ChoiceField(choices=Capabilities.choices))
|
||||||
|
|
||||||
cache_timeout = IntegerField(required=True)
|
cache_timeout = IntegerField(required=True)
|
||||||
@ -63,19 +52,23 @@ class ConfigView(APIView):
|
|||||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||||
if GEOIP_READER.enabled:
|
if GEOIP_READER.enabled:
|
||||||
caps.append(Capabilities.CAN_GEO_IP)
|
caps.append(Capabilities.CAN_GEO_IP)
|
||||||
|
if SERVICE_HOST_ENV_NAME in environ:
|
||||||
|
# Running in k8s, only s3 backup is supported
|
||||||
|
if CONFIG.y("postgresql.s3_backup"):
|
||||||
|
caps.append(Capabilities.CAN_BACKUP)
|
||||||
|
else:
|
||||||
|
# Running in compose, backup is always supported
|
||||||
|
caps.append(Capabilities.CAN_BACKUP)
|
||||||
return caps
|
return caps
|
||||||
|
|
||||||
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
||||||
def get(self, request: Request) -> Response:
|
def get(self, request: Request) -> Response:
|
||||||
"""Retrieve public configuration options"""
|
"""Retrive public configuration options"""
|
||||||
config = ConfigSerializer(
|
config = ConfigSerializer(
|
||||||
{
|
{
|
||||||
"error_reporting": {
|
"error_reporting_enabled": CONFIG.y("error_reporting.enabled"),
|
||||||
"enabled": CONFIG.y("error_reporting.enabled") and not settings.DEBUG,
|
"error_reporting_environment": CONFIG.y("error_reporting.environment"),
|
||||||
"environment": CONFIG.y("error_reporting.environment"),
|
"error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"),
|
||||||
"send_pii": CONFIG.y("error_reporting.send_pii"),
|
|
||||||
"traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)),
|
|
||||||
},
|
|
||||||
"capabilities": self.get_capabilities(),
|
"capabilities": self.get_capabilities(),
|
||||||
"cache_timeout": int(CONFIG.y("redis.cache_timeout")),
|
"cache_timeout": int(CONFIG.y("redis.cache_timeout")),
|
||||||
"cache_timeout_flows": int(CONFIG.y("redis.cache_timeout_flows")),
|
"cache_timeout_flows": int(CONFIG.y("redis.cache_timeout_flows")),
|
||||||
|
66
authentik/api/v3/sentry.py
Normal file
66
authentik/api/v3/sentry.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""Sentry tunnel"""
|
||||||
|
from json import loads
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http.request import HttpRequest
|
||||||
|
from django.http.response import HttpResponse
|
||||||
|
from requests import post
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from rest_framework.authentication import SessionAuthentication
|
||||||
|
from rest_framework.parsers import BaseParser
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
class PlainTextParser(BaseParser):
|
||||||
|
"""Plain text parser."""
|
||||||
|
|
||||||
|
media_type = "text/plain"
|
||||||
|
|
||||||
|
def parse(self, stream, media_type=None, parser_context=None) -> str:
|
||||||
|
"""Simply return a string representing the body of the request."""
|
||||||
|
return stream.read()
|
||||||
|
|
||||||
|
|
||||||
|
class CsrfExemptSessionAuthentication(SessionAuthentication):
|
||||||
|
"""CSRF-exempt Session authentication"""
|
||||||
|
|
||||||
|
def enforce_csrf(self, request: Request):
|
||||||
|
return # To not perform the csrf check previously happening
|
||||||
|
|
||||||
|
|
||||||
|
class SentryTunnelView(APIView):
|
||||||
|
"""Sentry tunnel, to prevent ad blockers from blocking sentry"""
|
||||||
|
|
||||||
|
serializer_class = None
|
||||||
|
parser_classes = [PlainTextParser]
|
||||||
|
throttle_classes = [AnonRateThrottle]
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = [CsrfExemptSessionAuthentication]
|
||||||
|
|
||||||
|
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
|
"""Sentry tunnel, to prevent ad blockers from blocking sentry"""
|
||||||
|
# Only allow usage of this endpoint when error reporting is enabled
|
||||||
|
if not CONFIG.y_bool("error_reporting.enabled", False):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
# Body is 2 json objects separated by \n
|
||||||
|
full_body = request.body
|
||||||
|
header = loads(full_body.splitlines()[0])
|
||||||
|
# Check that the DSN is what we expect
|
||||||
|
dsn = header.get("dsn", "")
|
||||||
|
if dsn != settings.SENTRY_DSN:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
response = post(
|
||||||
|
"https://sentry.beryju.org/api/8/envelope/",
|
||||||
|
data=full_body,
|
||||||
|
headers={"Content-Type": "application/octet-stream"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except RequestException:
|
||||||
|
return HttpResponse(status=500)
|
||||||
|
return HttpResponse(status=response.status_code)
|
@ -11,27 +11,25 @@ from authentik.admin.api.tasks import TaskViewSet
|
|||||||
from authentik.admin.api.version import VersionView
|
from authentik.admin.api.version import VersionView
|
||||||
from authentik.admin.api.workers import WorkerView
|
from authentik.admin.api.workers import WorkerView
|
||||||
from authentik.api.v3.config import ConfigView
|
from authentik.api.v3.config import ConfigView
|
||||||
|
from authentik.api.v3.sentry import SentryTunnelView
|
||||||
from authentik.api.views import APIBrowserView
|
from authentik.api.views import APIBrowserView
|
||||||
from authentik.core.api.applications import ApplicationViewSet
|
from authentik.core.api.applications import ApplicationViewSet
|
||||||
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
|
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
|
||||||
from authentik.core.api.devices import DeviceViewSet
|
|
||||||
from authentik.core.api.groups import GroupViewSet
|
from authentik.core.api.groups import GroupViewSet
|
||||||
from authentik.core.api.propertymappings import PropertyMappingViewSet
|
from authentik.core.api.propertymappings import PropertyMappingViewSet
|
||||||
from authentik.core.api.providers import ProviderViewSet
|
from authentik.core.api.providers import ProviderViewSet
|
||||||
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
|
from authentik.core.api.sources import SourceViewSet
|
||||||
from authentik.core.api.tokens import TokenViewSet
|
from authentik.core.api.tokens import TokenViewSet
|
||||||
from authentik.core.api.users import UserViewSet
|
from authentik.core.api.users import UserViewSet
|
||||||
from authentik.crypto.api import CertificateKeyPairViewSet
|
from authentik.crypto.api import CertificateKeyPairViewSet
|
||||||
from authentik.events.api.event import EventViewSet
|
from authentik.events.api.event import EventViewSet
|
||||||
from authentik.events.api.notification import NotificationViewSet
|
from authentik.events.api.notification import NotificationViewSet
|
||||||
from authentik.events.api.notification_mapping import NotificationWebhookMappingViewSet
|
|
||||||
from authentik.events.api.notification_rule import NotificationRuleViewSet
|
from authentik.events.api.notification_rule import NotificationRuleViewSet
|
||||||
from authentik.events.api.notification_transport import NotificationTransportViewSet
|
from authentik.events.api.notification_transport import NotificationTransportViewSet
|
||||||
from authentik.flows.api.bindings import FlowStageBindingViewSet
|
from authentik.flows.api.bindings import FlowStageBindingViewSet
|
||||||
from authentik.flows.api.flows import FlowViewSet
|
from authentik.flows.api.flows import FlowViewSet
|
||||||
from authentik.flows.api.stages import StageViewSet
|
from authentik.flows.api.stages import StageViewSet
|
||||||
from authentik.flows.views.executor import FlowExecutorView
|
from authentik.flows.views import FlowExecutorView
|
||||||
from authentik.flows.views.inspector import FlowInspectorView
|
|
||||||
from authentik.outposts.api.outposts import OutpostViewSet
|
from authentik.outposts.api.outposts import OutpostViewSet
|
||||||
from authentik.outposts.api.service_connections import (
|
from authentik.outposts.api.service_connections import (
|
||||||
DockerServiceConnectionViewSet,
|
DockerServiceConnectionViewSet,
|
||||||
@ -46,7 +44,11 @@ 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.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 (
|
||||||
|
IPReputationViewSet,
|
||||||
|
ReputationPolicyViewSet,
|
||||||
|
UserReputationViewSet,
|
||||||
|
)
|
||||||
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
||||||
from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
|
from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
|
||||||
from authentik.providers.oauth2.api.scope import ScopeMappingViewSet
|
from authentik.providers.oauth2.api.scope import ScopeMappingViewSet
|
||||||
@ -64,11 +66,6 @@ from authentik.stages.authenticator_duo.api import (
|
|||||||
DuoAdminDeviceViewSet,
|
DuoAdminDeviceViewSet,
|
||||||
DuoDeviceViewSet,
|
DuoDeviceViewSet,
|
||||||
)
|
)
|
||||||
from authentik.stages.authenticator_sms.api import (
|
|
||||||
AuthenticatorSMSStageViewSet,
|
|
||||||
SMSAdminDeviceViewSet,
|
|
||||||
SMSDeviceViewSet,
|
|
||||||
)
|
|
||||||
from authentik.stages.authenticator_static.api import (
|
from authentik.stages.authenticator_static.api import (
|
||||||
AuthenticatorStaticStageViewSet,
|
AuthenticatorStaticStageViewSet,
|
||||||
StaticAdminDeviceViewSet,
|
StaticAdminDeviceViewSet,
|
||||||
@ -101,7 +98,6 @@ from authentik.stages.user_write.api import UserWriteStageViewSet
|
|||||||
from authentik.tenants.api import TenantViewSet
|
from authentik.tenants.api import TenantViewSet
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.include_format_suffixes = False
|
|
||||||
|
|
||||||
router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
|
router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
|
||||||
router.register("admin/apps", AppsViewSet, basename="apps")
|
router.register("admin/apps", AppsViewSet, basename="apps")
|
||||||
@ -132,7 +128,6 @@ router.register("events/transports", NotificationTransportViewSet)
|
|||||||
router.register("events/rules", NotificationRuleViewSet)
|
router.register("events/rules", NotificationRuleViewSet)
|
||||||
|
|
||||||
router.register("sources/all", SourceViewSet)
|
router.register("sources/all", SourceViewSet)
|
||||||
router.register("sources/user_connections/all", UserSourceConnectionViewSet)
|
|
||||||
router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet)
|
router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet)
|
||||||
router.register("sources/user_connections/plex", PlexSourceConnectionViewSet)
|
router.register("sources/user_connections/plex", PlexSourceConnectionViewSet)
|
||||||
router.register("sources/ldap", LDAPSourceViewSet)
|
router.register("sources/ldap", LDAPSourceViewSet)
|
||||||
@ -147,7 +142,8 @@ router.register("policies/event_matcher", EventMatcherPolicyViewSet)
|
|||||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
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/users", UserReputationViewSet)
|
||||||
|
router.register("policies/reputation/ips", IPReputationViewSet)
|
||||||
router.register("policies/reputation", ReputationPolicyViewSet)
|
router.register("policies/reputation", ReputationPolicyViewSet)
|
||||||
|
|
||||||
router.register("providers/all", ProviderViewSet)
|
router.register("providers/all", ProviderViewSet)
|
||||||
@ -163,11 +159,8 @@ router.register("propertymappings/all", PropertyMappingViewSet)
|
|||||||
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
||||||
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
|
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
|
||||||
router.register("propertymappings/scope", ScopeMappingViewSet)
|
router.register("propertymappings/scope", ScopeMappingViewSet)
|
||||||
router.register("propertymappings/notification", NotificationWebhookMappingViewSet)
|
|
||||||
|
|
||||||
router.register("authenticators/all", DeviceViewSet, basename="device")
|
|
||||||
router.register("authenticators/duo", DuoDeviceViewSet)
|
router.register("authenticators/duo", DuoDeviceViewSet)
|
||||||
router.register("authenticators/sms", SMSDeviceViewSet)
|
|
||||||
router.register("authenticators/static", StaticDeviceViewSet)
|
router.register("authenticators/static", StaticDeviceViewSet)
|
||||||
router.register("authenticators/totp", TOTPDeviceViewSet)
|
router.register("authenticators/totp", TOTPDeviceViewSet)
|
||||||
router.register("authenticators/webauthn", WebAuthnDeviceViewSet)
|
router.register("authenticators/webauthn", WebAuthnDeviceViewSet)
|
||||||
@ -176,11 +169,6 @@ router.register(
|
|||||||
DuoAdminDeviceViewSet,
|
DuoAdminDeviceViewSet,
|
||||||
basename="admin-duodevice",
|
basename="admin-duodevice",
|
||||||
)
|
)
|
||||||
router.register(
|
|
||||||
"authenticators/admin/sms",
|
|
||||||
SMSAdminDeviceViewSet,
|
|
||||||
basename="admin-smsdevice",
|
|
||||||
)
|
|
||||||
router.register(
|
router.register(
|
||||||
"authenticators/admin/static",
|
"authenticators/admin/static",
|
||||||
StaticAdminDeviceViewSet,
|
StaticAdminDeviceViewSet,
|
||||||
@ -195,7 +183,6 @@ router.register(
|
|||||||
|
|
||||||
router.register("stages/all", StageViewSet)
|
router.register("stages/all", StageViewSet)
|
||||||
router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet)
|
router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet)
|
||||||
router.register("stages/authenticator/sms", AuthenticatorSMSStageViewSet)
|
|
||||||
router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet)
|
router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet)
|
||||||
router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet)
|
router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet)
|
||||||
router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet)
|
router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet)
|
||||||
@ -238,11 +225,7 @@ urlpatterns = (
|
|||||||
FlowExecutorView.as_view(),
|
FlowExecutorView.as_view(),
|
||||||
name="flow-executor",
|
name="flow-executor",
|
||||||
),
|
),
|
||||||
path(
|
path("sentry/", SentryTunnelView.as_view(), name="sentry"),
|
||||||
"flows/inspector/<slug:flow_slug>/",
|
|
||||||
FlowInspectorView.as_view(),
|
|
||||||
name="flow-inspector",
|
|
||||||
),
|
|
||||||
path("schema/", cache_page(86400)(SpectacularAPIView.as_view()), name="schema"),
|
path("schema/", cache_page(86400)(SpectacularAPIView.as_view()), name="schema"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -1,15 +1,12 @@
|
|||||||
"""Application API Views"""
|
"""Application API Views"""
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.http.response import HttpResponseBadRequest
|
from django.http.response import HttpResponseBadRequest
|
||||||
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
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import ReadOnlyField, SerializerMethodField
|
from rest_framework.fields import ReadOnlyField
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -18,7 +15,7 @@ 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
|
||||||
|
|
||||||
from authentik.admin.api.metrics import CoordinateSerializer
|
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.providers import ProviderSerializer
|
from authentik.core.api.providers import ProviderSerializer
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
@ -41,16 +38,11 @@ def user_app_cache_key(user_pk: str) -> str:
|
|||||||
class ApplicationSerializer(ModelSerializer):
|
class ApplicationSerializer(ModelSerializer):
|
||||||
"""Application Serializer"""
|
"""Application Serializer"""
|
||||||
|
|
||||||
launch_url = SerializerMethodField()
|
launch_url = ReadOnlyField(source="get_launch_url")
|
||||||
provider_obj = ProviderSerializer(source="get_provider", required=False)
|
provider_obj = ProviderSerializer(source="get_provider", required=False)
|
||||||
|
|
||||||
meta_icon = ReadOnlyField(source="get_meta_icon")
|
meta_icon = ReadOnlyField(source="get_meta_icon")
|
||||||
|
|
||||||
def get_launch_url(self, app: Application) -> Optional[str]:
|
|
||||||
"""Allow formatting of launch URL"""
|
|
||||||
user = self.context["request"].user
|
|
||||||
return app.get_launch_url(user)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Application
|
model = Application
|
||||||
@ -247,10 +239,8 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"""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")
|
get_events_per_1h(
|
||||||
.filter(
|
|
||||||
action=EventAction.AUTHORIZE_APPLICATION,
|
action=EventAction.AUTHORIZE_APPLICATION,
|
||||||
context__authorized_application__pk=app.pk.hex,
|
context__authorized_application__pk=app.pk.hex,
|
||||||
)
|
)
|
||||||
.get_events_per_hour()
|
|
||||||
)
|
)
|
||||||
|
@ -11,7 +11,6 @@ from rest_framework.serializers import ModelSerializer
|
|||||||
from rest_framework.viewsets import GenericViewSet
|
from rest_framework.viewsets import GenericViewSet
|
||||||
from ua_parser import user_agent_parser
|
from ua_parser import user_agent_parser
|
||||||
|
|
||||||
from authentik.api.authorization import OwnerSuperuserPermissions
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
from authentik.events.geo import GEOIP_READER, GeoIPDict
|
from authentik.events.geo import GEOIP_READER, GeoIPDict
|
||||||
@ -103,8 +102,11 @@ class AuthenticatedSessionViewSet(
|
|||||||
search_fields = ["user__username", "last_ip", "last_user_agent"]
|
search_fields = ["user__username", "last_ip", "last_user_agent"]
|
||||||
filterset_fields = ["user__username", "last_ip", "last_user_agent"]
|
filterset_fields = ["user__username", "last_ip", "last_user_agent"]
|
||||||
ordering = ["user__username"]
|
ordering = ["user__username"]
|
||||||
permission_classes = [OwnerSuperuserPermissions]
|
filter_backends = [
|
||||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
DjangoFilterBackend,
|
||||||
|
OrderingFilter,
|
||||||
|
SearchFilter,
|
||||||
|
]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
user = self.request.user if self.request else get_anonymous_user()
|
user = self.request.user if self.request else get_anonymous_user()
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
"""Authenticator Devices API Views"""
|
|
||||||
from django_otp import devices_for_user
|
|
||||||
from django_otp.models import Device
|
|
||||||
from drf_spectacular.utils import extend_schema
|
|
||||||
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.viewsets import ViewSet
|
|
||||||
|
|
||||||
from authentik.core.api.utils import MetaNameSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceSerializer(MetaNameSerializer):
|
|
||||||
"""Serializer for Duo authenticator devices"""
|
|
||||||
|
|
||||||
pk = IntegerField()
|
|
||||||
name = CharField()
|
|
||||||
type = SerializerMethodField()
|
|
||||||
|
|
||||||
def get_type(self, instance: Device) -> str:
|
|
||||||
"""Get type of device"""
|
|
||||||
return instance._meta.label
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceViewSet(ViewSet):
|
|
||||||
"""Viewset for authenticator devices"""
|
|
||||||
|
|
||||||
serializer_class = DeviceSerializer
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
|
|
||||||
@extend_schema(responses={200: DeviceSerializer(many=True)})
|
|
||||||
def list(self, request: Request) -> Response:
|
|
||||||
"""Get all devices for current user"""
|
|
||||||
devices = devices_for_user(request.user)
|
|
||||||
return Response(DeviceSerializer(devices, many=True).data)
|
|
@ -1,11 +1,9 @@
|
|||||||
"""Groups API Viewset"""
|
"""Groups API Viewset"""
|
||||||
from json import loads
|
|
||||||
|
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
|
from django_filters.filters import ModelMultipleChoiceFilter
|
||||||
from django_filters.filterset import FilterSet
|
from django_filters.filterset import FilterSet
|
||||||
from rest_framework.fields import CharField, JSONField
|
from rest_framework.fields import BooleanField, CharField, JSONField
|
||||||
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
|
from rest_framework.serializers import ListSerializer, ModelSerializer
|
||||||
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
|
||||||
|
|
||||||
@ -17,6 +15,7 @@ from authentik.core.models import Group, User
|
|||||||
class GroupMemberSerializer(ModelSerializer):
|
class GroupMemberSerializer(ModelSerializer):
|
||||||
"""Stripped down user serializer to show relevant users for groups"""
|
"""Stripped down user serializer to show relevant users for groups"""
|
||||||
|
|
||||||
|
is_superuser = BooleanField(read_only=True)
|
||||||
avatar = CharField(read_only=True)
|
avatar = CharField(read_only=True)
|
||||||
attributes = JSONField(validators=[is_dict], required=False)
|
attributes = JSONField(validators=[is_dict], required=False)
|
||||||
uid = CharField(read_only=True)
|
uid = CharField(read_only=True)
|
||||||
@ -30,6 +29,7 @@ class GroupMemberSerializer(ModelSerializer):
|
|||||||
"name",
|
"name",
|
||||||
"is_active",
|
"is_active",
|
||||||
"last_login",
|
"last_login",
|
||||||
|
"is_superuser",
|
||||||
"email",
|
"email",
|
||||||
"avatar",
|
"avatar",
|
||||||
"attributes",
|
"attributes",
|
||||||
@ -44,7 +44,6 @@ class GroupSerializer(ModelSerializer):
|
|||||||
users_obj = ListSerializer(
|
users_obj = ListSerializer(
|
||||||
child=GroupMemberSerializer(), read_only=True, source="users", required=False
|
child=GroupMemberSerializer(), read_only=True, source="users", required=False
|
||||||
)
|
)
|
||||||
parent_name = CharField(source="parent.name", read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
@ -54,7 +53,6 @@ class GroupSerializer(ModelSerializer):
|
|||||||
"name",
|
"name",
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
"parent",
|
"parent",
|
||||||
"parent_name",
|
|
||||||
"users",
|
"users",
|
||||||
"attributes",
|
"attributes",
|
||||||
"users_obj",
|
"users_obj",
|
||||||
@ -64,13 +62,6 @@ class GroupSerializer(ModelSerializer):
|
|||||||
class GroupFilter(FilterSet):
|
class GroupFilter(FilterSet):
|
||||||
"""Filter for groups"""
|
"""Filter for groups"""
|
||||||
|
|
||||||
attributes = CharFilter(
|
|
||||||
field_name="attributes",
|
|
||||||
lookup_expr="",
|
|
||||||
label="Attributes",
|
|
||||||
method="filter_attributes",
|
|
||||||
)
|
|
||||||
|
|
||||||
members_by_username = ModelMultipleChoiceFilter(
|
members_by_username = ModelMultipleChoiceFilter(
|
||||||
field_name="users__username",
|
field_name="users__username",
|
||||||
to_field_name="username",
|
to_field_name="username",
|
||||||
@ -81,28 +72,10 @@ class GroupFilter(FilterSet):
|
|||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_attributes(self, queryset, name, value):
|
|
||||||
"""Filter attributes by query args"""
|
|
||||||
try:
|
|
||||||
value = loads(value)
|
|
||||||
except ValueError:
|
|
||||||
raise ValidationError(detail="filter: failed to parse JSON")
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValidationError(detail="filter: value must be key:value mapping")
|
|
||||||
qs = {}
|
|
||||||
for key, _value in value.items():
|
|
||||||
qs[f"attributes__{key}"] = _value
|
|
||||||
try:
|
|
||||||
_ = len(queryset.filter(**qs))
|
|
||||||
return queryset.filter(**qs)
|
|
||||||
except ValueError:
|
|
||||||
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", "members_by_username"]
|
||||||
|
|
||||||
|
|
||||||
class GroupViewSet(UsedByMixin, ModelViewSet):
|
class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||||
|
@ -56,7 +56,6 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
|
|||||||
"component",
|
"component",
|
||||||
"verbose_name",
|
"verbose_name",
|
||||||
"verbose_name_plural",
|
"verbose_name_plural",
|
||||||
"meta_model_name",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,7 +43,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
|||||||
"assigned_application_name",
|
"assigned_application_name",
|
||||||
"verbose_name",
|
"verbose_name",
|
||||||
"verbose_name_plural",
|
"verbose_name_plural",
|
||||||
"meta_model_name",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,21 +1,18 @@
|
|||||||
"""Source API Views"""
|
"""Source API Views"""
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import mixins
|
from rest_framework import mixins
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||||
from rest_framework.viewsets import GenericViewSet
|
from rest_framework.viewsets import GenericViewSet
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
|
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
|
||||||
from authentik.core.models import Source, UserSourceConnection
|
from authentik.core.models import Source
|
||||||
from authentik.core.types import UserSettingSerializer
|
from authentik.core.types import UserSettingSerializer
|
||||||
from authentik.lib.utils.reflection import all_subclasses
|
from authentik.lib.utils.reflection import all_subclasses
|
||||||
from authentik.policies.engine import PolicyEngine
|
from authentik.policies.engine import PolicyEngine
|
||||||
@ -48,7 +45,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
|||||||
"component",
|
"component",
|
||||||
"verbose_name",
|
"verbose_name",
|
||||||
"verbose_name_plural",
|
"verbose_name_plural",
|
||||||
"meta_model_name",
|
|
||||||
"policy_engine_mode",
|
"policy_engine_mode",
|
||||||
"user_matching_mode",
|
"user_matching_mode",
|
||||||
]
|
]
|
||||||
@ -99,57 +95,19 @@ class SourceViewSet(
|
|||||||
@action(detail=False, pagination_class=None, filter_backends=[])
|
@action(detail=False, pagination_class=None, filter_backends=[])
|
||||||
def user_settings(self, request: Request) -> Response:
|
def user_settings(self, request: Request) -> Response:
|
||||||
"""Get all sources the user can configure"""
|
"""Get all sources the user can configure"""
|
||||||
_all_sources: Iterable[Source] = (
|
_all_sources: Iterable[Source] = Source.objects.filter(enabled=True).select_subclasses()
|
||||||
Source.objects.filter(enabled=True).select_subclasses().order_by("name")
|
|
||||||
)
|
|
||||||
matching_sources: list[UserSettingSerializer] = []
|
matching_sources: list[UserSettingSerializer] = []
|
||||||
for source in _all_sources:
|
for source in _all_sources:
|
||||||
user_settings = source.ui_user_settings()
|
user_settings = source.ui_user_settings
|
||||||
if not user_settings:
|
if not user_settings:
|
||||||
continue
|
continue
|
||||||
policy_engine = PolicyEngine(source, request.user, request)
|
policy_engine = PolicyEngine(source, request.user, request)
|
||||||
policy_engine.build()
|
policy_engine.build()
|
||||||
if not policy_engine.passing:
|
if not policy_engine.passing:
|
||||||
continue
|
continue
|
||||||
source_settings = source.ui_user_settings()
|
source_settings = source.ui_user_settings
|
||||||
source_settings.initial_data["object_uid"] = source.slug
|
source_settings.initial_data["object_uid"] = source.slug
|
||||||
if not source_settings.is_valid():
|
if not source_settings.is_valid():
|
||||||
LOGGER.warning(source_settings.errors)
|
LOGGER.warning(source_settings.errors)
|
||||||
matching_sources.append(source_settings.validated_data)
|
matching_sources.append(source_settings.validated_data)
|
||||||
return Response(matching_sources)
|
return Response(matching_sources)
|
||||||
|
|
||||||
|
|
||||||
class UserSourceConnectionSerializer(SourceSerializer):
|
|
||||||
"""OAuth Source Serializer"""
|
|
||||||
|
|
||||||
source = SourceSerializer(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = UserSourceConnection
|
|
||||||
fields = [
|
|
||||||
"pk",
|
|
||||||
"user",
|
|
||||||
"source",
|
|
||||||
"created",
|
|
||||||
]
|
|
||||||
extra_kwargs = {
|
|
||||||
"user": {"read_only": True},
|
|
||||||
"created": {"read_only": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class UserSourceConnectionViewSet(
|
|
||||||
mixins.RetrieveModelMixin,
|
|
||||||
mixins.UpdateModelMixin,
|
|
||||||
mixins.DestroyModelMixin,
|
|
||||||
UsedByMixin,
|
|
||||||
mixins.ListModelMixin,
|
|
||||||
GenericViewSet,
|
|
||||||
):
|
|
||||||
"""User-source connection Viewset"""
|
|
||||||
|
|
||||||
queryset = UserSourceConnection.objects.all()
|
|
||||||
serializer_class = UserSourceConnectionSerializer
|
|
||||||
permission_classes = [OwnerPermissions]
|
|
||||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
|
||||||
ordering = ["pk"]
|
|
||||||
|
@ -1,19 +1,16 @@
|
|||||||
"""Tokens API Viewset"""
|
"""Tokens API Viewset"""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django.http.response import Http404
|
||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
from guardian.shortcuts import assign_perm, get_anonymous_user
|
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.fields import CharField
|
from rest_framework.fields import CharField
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ModelSerializer
|
from rest_framework.serializers import ModelSerializer
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.api.authorization import OwnerSuperuserPermissions
|
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.users import UserSerializer
|
from authentik.core.api.users import UserSerializer
|
||||||
@ -81,27 +78,14 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"description",
|
"description",
|
||||||
"expires",
|
"expires",
|
||||||
"expiring",
|
"expiring",
|
||||||
"managed",
|
|
||||||
]
|
]
|
||||||
ordering = ["identifier", "expires"]
|
ordering = ["expires"]
|
||||||
permission_classes = [OwnerSuperuserPermissions]
|
|
||||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
user = self.request.user if self.request else get_anonymous_user()
|
|
||||||
if user.is_superuser:
|
|
||||||
return super().get_queryset()
|
|
||||||
return super().get_queryset().filter(user=user.pk)
|
|
||||||
|
|
||||||
def perform_create(self, serializer: TokenSerializer):
|
def perform_create(self, serializer: TokenSerializer):
|
||||||
if not self.request.user.is_superuser:
|
serializer.save(
|
||||||
instance = serializer.save(
|
user=self.request.user,
|
||||||
user=self.request.user,
|
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
|
||||||
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
|
)
|
||||||
)
|
|
||||||
assign_perm("authentik_core.view_token_key", self.request.user, instance)
|
|
||||||
return instance
|
|
||||||
return super().perform_create(serializer)
|
|
||||||
|
|
||||||
@permission_required("authentik_core.view_token_key")
|
@permission_required("authentik_core.view_token_key")
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@ -115,5 +99,7 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
def view_key(self, request: Request, identifier: str) -> Response:
|
def view_key(self, request: Request, identifier: str) -> Response:
|
||||||
"""Return token key and log access"""
|
"""Return token key and log access"""
|
||||||
token: Token = self.get_object()
|
token: Token = self.get_object()
|
||||||
|
if token.is_expired:
|
||||||
|
raise Http404
|
||||||
Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request) # noqa # nosec
|
Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request) # noqa # nosec
|
||||||
return Response(TokenViewSerializer({"key": token.key}).data)
|
return Response(TokenViewSerializer({"key": token.key}).data)
|
||||||
|
@ -1,16 +1,12 @@
|
|||||||
"""User API Views"""
|
"""User API Views"""
|
||||||
from datetime import timedelta
|
|
||||||
from json import loads
|
from json import loads
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.contrib.auth import update_session_auth_hash
|
|
||||||
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
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
from django.utils.text import slugify
|
|
||||||
from django.utils.timezone import now
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django_filters.filters import BooleanFilter, CharFilter, ModelMultipleChoiceFilter
|
from django_filters.filters import BooleanFilter, CharFilter, ModelMultipleChoiceFilter
|
||||||
from django_filters.filterset import FilterSet
|
from django_filters.filterset import FilterSet
|
||||||
@ -23,7 +19,8 @@ from drf_spectacular.utils import (
|
|||||||
)
|
)
|
||||||
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
|
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import CharField, DictField, JSONField, SerializerMethodField
|
from rest_framework.fields import CharField, JSONField, SerializerMethodField
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import (
|
from rest_framework.serializers import (
|
||||||
@ -38,7 +35,7 @@ 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
|
||||||
|
|
||||||
from authentik.admin.api.metrics import CoordinateSerializer
|
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.groups import GroupSerializer
|
from authentik.core.api.groups import GroupSerializer
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
@ -90,9 +87,6 @@ class UserSerializer(ModelSerializer):
|
|||||||
"attributes",
|
"attributes",
|
||||||
"uid",
|
"uid",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
|
||||||
"name": {"allow_blank": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class UserSelfSerializer(ModelSerializer):
|
class UserSelfSerializer(ModelSerializer):
|
||||||
@ -101,25 +95,8 @@ class UserSelfSerializer(ModelSerializer):
|
|||||||
|
|
||||||
is_superuser = BooleanField(read_only=True)
|
is_superuser = BooleanField(read_only=True)
|
||||||
avatar = CharField(read_only=True)
|
avatar = CharField(read_only=True)
|
||||||
groups = SerializerMethodField()
|
groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
|
||||||
uid = CharField(read_only=True)
|
uid = CharField(read_only=True)
|
||||||
settings = DictField(source="attributes.settings", default=dict)
|
|
||||||
|
|
||||||
@extend_schema_field(
|
|
||||||
ListSerializer(
|
|
||||||
child=inline_serializer(
|
|
||||||
"UserSelfGroups",
|
|
||||||
{"name": CharField(read_only=True), "pk": CharField(read_only=True)},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
def get_groups(self, _: User):
|
|
||||||
"""Return only the group names a user is member of"""
|
|
||||||
for group in self.instance.ak_groups.all():
|
|
||||||
yield {
|
|
||||||
"name": group.name,
|
|
||||||
"pk": group.pk,
|
|
||||||
}
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
@ -134,11 +111,9 @@ class UserSelfSerializer(ModelSerializer):
|
|||||||
"email",
|
"email",
|
||||||
"avatar",
|
"avatar",
|
||||||
"uid",
|
"uid",
|
||||||
"settings",
|
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"is_active": {"read_only": True},
|
"is_active": {"read_only": True},
|
||||||
"name": {"allow_blank": True},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -161,31 +136,19 @@ class UserMetricsSerializer(PassiveSerializer):
|
|||||||
def get_logins_per_1h(self, _):
|
def get_logins_per_1h(self, _):
|
||||||
"""Get successful logins per hour for the last 24 hours"""
|
"""Get successful logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk)
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
|
||||||
.filter(action=EventAction.LOGIN, user__pk=user.pk)
|
|
||||||
.get_events_per_hour()
|
|
||||||
)
|
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed_per_1h(self, _):
|
def get_logins_failed_per_1h(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return get_events_per_1h(action=EventAction.LOGIN_FAILED, context__username=user.username)
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
|
||||||
.filter(action=EventAction.LOGIN_FAILED, context__username=user.username)
|
|
||||||
.get_events_per_hour()
|
|
||||||
)
|
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_authorizations_per_1h(self, _):
|
def get_authorizations_per_1h(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return get_events_per_1h(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
|
||||||
.filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
|
|
||||||
.get_events_per_hour()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UsersFilter(FilterSet):
|
class UsersFilter(FilterSet):
|
||||||
@ -199,7 +162,6 @@ class UsersFilter(FilterSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
|
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
|
||||||
uuid = CharFilter(field_name="uuid")
|
|
||||||
|
|
||||||
groups_by_name = ModelMultipleChoiceFilter(
|
groups_by_name = ModelMultipleChoiceFilter(
|
||||||
field_name="ak_groups__name",
|
field_name="ak_groups__name",
|
||||||
@ -223,11 +185,7 @@ class UsersFilter(FilterSet):
|
|||||||
qs = {}
|
qs = {}
|
||||||
for key, _value in value.items():
|
for key, _value in value.items():
|
||||||
qs[f"attributes__{key}"] = _value
|
qs[f"attributes__{key}"] = _value
|
||||||
try:
|
return queryset.filter(**qs)
|
||||||
_ = len(queryset.filter(**qs))
|
|
||||||
return queryset.filter(**qs)
|
|
||||||
except ValueError:
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@ -247,9 +205,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"""User Viewset"""
|
"""User Viewset"""
|
||||||
|
|
||||||
queryset = User.objects.none()
|
queryset = User.objects.none()
|
||||||
ordering = ["username"]
|
|
||||||
serializer_class = UserSerializer
|
serializer_class = UserSerializer
|
||||||
search_fields = ["username", "name", "is_active", "email", "uuid"]
|
search_fields = ["username", "name", "is_active", "email"]
|
||||||
filterset_class = UsersFilter
|
filterset_class = UsersFilter
|
||||||
|
|
||||||
def get_queryset(self): # pragma: no cover
|
def get_queryset(self): # pragma: no cover
|
||||||
@ -308,16 +265,15 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
name=username,
|
name=username,
|
||||||
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
|
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
|
||||||
)
|
)
|
||||||
if create_group and self.request.user.has_perm("authentik_core.add_group"):
|
if create_group:
|
||||||
group = Group.objects.create(
|
group = Group.objects.create(
|
||||||
name=username,
|
name=username,
|
||||||
)
|
)
|
||||||
group.users.add(user)
|
group.users.add(user)
|
||||||
token = Token.objects.create(
|
token = Token.objects.create(
|
||||||
identifier=slugify(f"service-account-{username}-password"),
|
identifier=f"service-account-{username}-password",
|
||||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||||
user=user,
|
user=user,
|
||||||
expires=now() + timedelta(days=360),
|
|
||||||
)
|
)
|
||||||
return Response({"username": user.username, "token": token.key})
|
return Response({"username": user.username, "token": token.key})
|
||||||
except (IntegrityError) as exc:
|
except (IntegrityError) as exc:
|
||||||
@ -328,43 +284,35 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
def me(self, request: Request) -> Response:
|
def me(self, request: Request) -> Response:
|
||||||
"""Get information about current user"""
|
"""Get information about current user"""
|
||||||
serializer = SessionUserSerializer(
|
serializer = SessionUserSerializer(data={"user": UserSelfSerializer(request.user).data})
|
||||||
data={"user": UserSelfSerializer(instance=request.user).data}
|
|
||||||
)
|
|
||||||
if SESSION_IMPERSONATE_USER in request._request.session:
|
if SESSION_IMPERSONATE_USER in request._request.session:
|
||||||
serializer.initial_data["original"] = UserSelfSerializer(
|
serializer.initial_data["original"] = UserSelfSerializer(
|
||||||
instance=request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
||||||
).data
|
).data
|
||||||
return Response(serializer.initial_data)
|
serializer.is_valid()
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
@permission_required("authentik_core.reset_user_password")
|
@extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)})
|
||||||
@extend_schema(
|
@action(
|
||||||
request=inline_serializer(
|
methods=["PUT"],
|
||||||
"UserPasswordSetSerializer",
|
detail=False,
|
||||||
{
|
pagination_class=None,
|
||||||
"password": CharField(required=True),
|
filter_backends=[],
|
||||||
},
|
permission_classes=[IsAuthenticated],
|
||||||
),
|
|
||||||
responses={
|
|
||||||
204: "",
|
|
||||||
400: "",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"])
|
def update_self(self, request: Request) -> Response:
|
||||||
# pylint: disable=invalid-name, unused-argument
|
"""Allow users to change information on their own profile"""
|
||||||
def set_password(self, request: Request, pk: int) -> Response:
|
data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data)
|
||||||
"""Set password for user"""
|
if not data.is_valid():
|
||||||
user: User = self.get_object()
|
return Response(data.errors)
|
||||||
try:
|
new_user = data.save()
|
||||||
user.set_password(request.data.get("password"))
|
# If we're impersonating, we need to update that user object
|
||||||
user.save()
|
# since it caches the full object
|
||||||
except (ValidationError, IntegrityError) as exc:
|
if SESSION_IMPERSONATE_USER in request.session:
|
||||||
LOGGER.debug("Failed to set password", exc=exc)
|
request.session[SESSION_IMPERSONATE_USER] = new_user
|
||||||
return Response(status=400)
|
serializer = SessionUserSerializer(data={"user": UserSelfSerializer(request.user).data})
|
||||||
if user.pk == request.user.pk and SESSION_IMPERSONATE_USER not in self.request.session:
|
serializer.is_valid()
|
||||||
LOGGER.debug("Updating session hash after password change")
|
return Response(serializer.data)
|
||||||
update_session_auth_hash(self.request, user)
|
|
||||||
return Response(status=204)
|
|
||||||
|
|
||||||
@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)})
|
||||||
|
@ -41,7 +41,6 @@ class MetaNameSerializer(PassiveSerializer):
|
|||||||
|
|
||||||
verbose_name = SerializerMethodField()
|
verbose_name = SerializerMethodField()
|
||||||
verbose_name_plural = SerializerMethodField()
|
verbose_name_plural = SerializerMethodField()
|
||||||
meta_model_name = SerializerMethodField()
|
|
||||||
|
|
||||||
def get_verbose_name(self, obj: Model) -> str:
|
def get_verbose_name(self, obj: Model) -> str:
|
||||||
"""Return object's verbose_name"""
|
"""Return object's verbose_name"""
|
||||||
@ -51,10 +50,6 @@ class MetaNameSerializer(PassiveSerializer):
|
|||||||
"""Return object's plural verbose_name"""
|
"""Return object's plural verbose_name"""
|
||||||
return obj._meta.verbose_name_plural
|
return obj._meta.verbose_name_plural
|
||||||
|
|
||||||
def get_meta_model_name(self, obj: Model) -> str:
|
|
||||||
"""Return internal model name"""
|
|
||||||
return f"{obj._meta.app_label}.{obj._meta.model_name}"
|
|
||||||
|
|
||||||
|
|
||||||
class TypeCreateSerializer(PassiveSerializer):
|
class TypeCreateSerializer(PassiveSerializer):
|
||||||
"""Types of an object that can be created"""
|
"""Types of an object that can be created"""
|
||||||
|
@ -8,7 +8,7 @@ from django.http.request import HttpRequest
|
|||||||
from authentik.core.models import Token, TokenIntents, User
|
from authentik.core.models import Token, TokenIntents, User
|
||||||
from authentik.events.utils import cleanse_dict, sanitize_dict
|
from authentik.events.utils import cleanse_dict, sanitize_dict
|
||||||
from authentik.flows.planner import FlowPlan
|
from authentik.flows.planner import FlowPlan
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
from authentik.flows.views import SESSION_KEY_PLAN
|
||||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||||
|
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ class InbuiltBackend(ModelBackend):
|
|||||||
return
|
return
|
||||||
# Since we can't directly pass other variables to signals, and we want to log the method
|
# Since we can't directly pass other variables to signals, and we want to log the method
|
||||||
# and the token used, we assume we're running in a flow and set a variable in the context
|
# and the token used, we assume we're running in a flow and set a variable in the context
|
||||||
flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan(""))
|
flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||||
flow_plan.context[PLAN_CONTEXT_METHOD] = method
|
flow_plan.context[PLAN_CONTEXT_METHOD] = method
|
||||||
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs))
|
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs))
|
||||||
request.session[SESSION_KEY_PLAN] = flow_plan
|
request.session[SESSION_KEY_PLAN] = flow_plan
|
||||||
@ -55,5 +55,5 @@ class TokenBackend(InbuiltBackend):
|
|||||||
if not tokens.exists():
|
if not tokens.exists():
|
||||||
return None
|
return None
|
||||||
token = tokens.first()
|
token = tokens.first()
|
||||||
self.set_method("token", request, token=token)
|
self.set_method("password", request, token=token)
|
||||||
return token.user
|
return token.user
|
||||||
|
@ -5,14 +5,11 @@ from typing import Callable
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from sentry_sdk.api import set_tag
|
|
||||||
|
|
||||||
SESSION_IMPERSONATE_USER = "authentik_impersonate_user"
|
SESSION_IMPERSONATE_USER = "authentik_impersonate_user"
|
||||||
SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user"
|
SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user"
|
||||||
LOCAL = local()
|
LOCAL = local()
|
||||||
RESPONSE_HEADER_ID = "X-authentik-id"
|
RESPONSE_HEADER_ID = "X-authentik-id"
|
||||||
KEY_AUTH_VIA = "auth_via"
|
|
||||||
KEY_USER = "user"
|
|
||||||
|
|
||||||
|
|
||||||
class ImpersonateMiddleware:
|
class ImpersonateMiddleware:
|
||||||
@ -51,22 +48,17 @@ class RequestIDMiddleware:
|
|||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"host": request.get_host(),
|
"host": request.get_host(),
|
||||||
}
|
}
|
||||||
set_tag("authentik.request_id", request_id)
|
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
response[RESPONSE_HEADER_ID] = request.request_id
|
response[RESPONSE_HEADER_ID] = request.request_id
|
||||||
setattr(response, "ak_context", {})
|
del LOCAL.authentik["request_id"]
|
||||||
response.ak_context.update(LOCAL.authentik)
|
del LOCAL.authentik["host"]
|
||||||
response.ak_context[KEY_USER] = request.user.username
|
|
||||||
for key in list(LOCAL.authentik.keys()):
|
|
||||||
del LOCAL.authentik[key]
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def structlog_add_request_id(logger: Logger, method_name: str, event_dict: dict):
|
def structlog_add_request_id(logger: Logger, method_name: str, event_dict):
|
||||||
"""If threadlocal has authentik defined, add request_id to log"""
|
"""If threadlocal has authentik defined, add request_id to log"""
|
||||||
if hasattr(LOCAL, "authentik"):
|
if hasattr(LOCAL, "authentik"):
|
||||||
event_dict.update(LOCAL.authentik)
|
event_dict["request_id"] = LOCAL.authentik.get("request_id", "")
|
||||||
if hasattr(LOCAL, "authentik_task"):
|
event_dict["host"] = LOCAL.authentik.get("host", "")
|
||||||
event_dict.update(LOCAL.authentik_task)
|
|
||||||
return event_dict
|
return event_dict
|
||||||
|
@ -1,221 +0,0 @@
|
|||||||
# Generated by Django 3.2.8 on 2021-10-10 16:16
|
|
||||||
|
|
||||||
from os import environ
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.apps.registry import Apps
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|
||||||
|
|
||||||
import authentik.core.models
|
|
||||||
|
|
||||||
|
|
||||||
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|
||||||
# We have to use a direct import here, otherwise we get an object manager error
|
|
||||||
from authentik.core.models import User
|
|
||||||
|
|
||||||
db_alias = schema_editor.connection.alias
|
|
||||||
|
|
||||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
|
||||||
username="akadmin", email="root@localhost", name="authentik Default Admin"
|
|
||||||
)
|
|
||||||
if "TF_BUILD" in environ or "AK_ADMIN_PASS" in environ or settings.TEST:
|
|
||||||
akadmin.set_password(environ.get("AK_ADMIN_PASS", "akadmin"), signal=False) # noqa # nosec
|
|
||||||
else:
|
|
||||||
akadmin.set_unusable_password()
|
|
||||||
akadmin.save()
|
|
||||||
|
|
||||||
|
|
||||||
def create_default_admin_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|
||||||
db_alias = schema_editor.connection.alias
|
|
||||||
Group = apps.get_model("authentik_core", "Group")
|
|
||||||
User = apps.get_model("authentik_core", "User")
|
|
||||||
|
|
||||||
# Creates a default admin group
|
|
||||||
group, _ = Group.objects.using(db_alias).get_or_create(
|
|
||||||
is_superuser=True,
|
|
||||||
defaults={
|
|
||||||
"name": "authentik Admins",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
group.users.set(User.objects.filter(username="akadmin"))
|
|
||||||
group.save()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
replaces = [
|
|
||||||
("authentik_core", "0002_auto_20200523_1133"),
|
|
||||||
("authentik_core", "0003_default_user"),
|
|
||||||
("authentik_core", "0004_auto_20200703_2213"),
|
|
||||||
("authentik_core", "0005_token_intent"),
|
|
||||||
("authentik_core", "0006_auto_20200709_1608"),
|
|
||||||
("authentik_core", "0007_auto_20200815_1841"),
|
|
||||||
("authentik_core", "0008_auto_20200824_1532"),
|
|
||||||
("authentik_core", "0009_group_is_superuser"),
|
|
||||||
("authentik_core", "0010_auto_20200917_1021"),
|
|
||||||
("authentik_core", "0011_provider_name_temp"),
|
|
||||||
]
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_core", "0001_initial"),
|
|
||||||
("authentik_flows", "0003_auto_20200523_1133"),
|
|
||||||
("auth", "0012_alter_user_first_name_max_length"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="application",
|
|
||||||
name="skip_authorization",
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="source",
|
|
||||||
name="authentication_flow",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
default=None,
|
|
||||||
help_text="Flow to use when authenticating existing users.",
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
related_name="source_authentication",
|
|
||||||
to="authentik_flows.flow",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="source",
|
|
||||||
name="enrollment_flow",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
default=None,
|
|
||||||
help_text="Flow to use when enrolling new users.",
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
related_name="source_enrollment",
|
|
||||||
to="authentik_flows.flow",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="provider",
|
|
||||||
name="authorization_flow",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
help_text="Flow used when authorizing this provider.",
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="provider_authorization",
|
|
||||||
to="authentik_flows.flow",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="user",
|
|
||||||
name="is_superuser",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="user",
|
|
||||||
name="is_staff",
|
|
||||||
),
|
|
||||||
migrations.RunPython(
|
|
||||||
code=create_default_user,
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="user",
|
|
||||||
name="is_superuser",
|
|
||||||
field=models.BooleanField(default=False),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="user",
|
|
||||||
name="is_staff",
|
|
||||||
field=models.BooleanField(default=False),
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="application",
|
|
||||||
options={"verbose_name": "Application", "verbose_name_plural": "Applications"},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="user",
|
|
||||||
options={
|
|
||||||
"permissions": (("reset_user_password", "Reset Password"),),
|
|
||||||
"verbose_name": "User",
|
|
||||||
"verbose_name_plural": "Users",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="token",
|
|
||||||
name="intent",
|
|
||||||
field=models.TextField(
|
|
||||||
choices=[("verification", "Intent Verification"), ("api", "Intent Api")],
|
|
||||||
default="verification",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="source",
|
|
||||||
name="slug",
|
|
||||||
field=models.SlugField(help_text="Internal source name, used in URLs.", unique=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="user",
|
|
||||||
name="first_name",
|
|
||||||
field=models.CharField(blank=True, max_length=150, verbose_name="first name"),
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="user",
|
|
||||||
name="groups",
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="user",
|
|
||||||
name="groups",
|
|
||||||
field=models.ManyToManyField(
|
|
||||||
blank=True,
|
|
||||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
|
||||||
related_name="user_set",
|
|
||||||
related_query_name="user",
|
|
||||||
to="auth.Group",
|
|
||||||
verbose_name="groups",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="user",
|
|
||||||
name="is_superuser",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="user",
|
|
||||||
name="is_staff",
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="user",
|
|
||||||
name="pb_groups",
|
|
||||||
field=models.ManyToManyField(related_name="users", to="authentik_core.Group"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="group",
|
|
||||||
name="is_superuser",
|
|
||||||
field=models.BooleanField(
|
|
||||||
default=False, help_text="Users added to this group will be superusers."
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RunPython(
|
|
||||||
code=create_default_admin_group,
|
|
||||||
),
|
|
||||||
migrations.AlterModelManagers(
|
|
||||||
name="user",
|
|
||||||
managers=[
|
|
||||||
("objects", authentik.core.models.UserManager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="user",
|
|
||||||
options={
|
|
||||||
"permissions": (
|
|
||||||
("reset_user_password", "Reset Password"),
|
|
||||||
("impersonate", "Can impersonate other users"),
|
|
||||||
),
|
|
||||||
"verbose_name": "User",
|
|
||||||
"verbose_name_plural": "Users",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="provider",
|
|
||||||
name="name_temp",
|
|
||||||
field=models.TextField(default=""),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,118 +0,0 @@
|
|||||||
# Generated by Django 3.2.8 on 2021-10-12 15:36
|
|
||||||
|
|
||||||
from django.apps.registry import Apps
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|
||||||
|
|
||||||
import authentik.core.models
|
|
||||||
|
|
||||||
|
|
||||||
def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|
||||||
db_alias = schema_editor.connection.alias
|
|
||||||
Token = apps.get_model("authentik_core", "Token")
|
|
||||||
|
|
||||||
for token in Token.objects.using(db_alias).all():
|
|
||||||
token.key = token.pk.hex
|
|
||||||
token.save()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
replaces = [
|
|
||||||
("authentik_core", "0012_auto_20201003_1737"),
|
|
||||||
("authentik_core", "0013_auto_20201003_2132"),
|
|
||||||
("authentik_core", "0014_auto_20201018_1158"),
|
|
||||||
("authentik_core", "0015_application_icon"),
|
|
||||||
("authentik_core", "0016_auto_20201202_2234"),
|
|
||||||
]
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_providers_saml", "0006_remove_samlprovider_name"),
|
|
||||||
("authentik_providers_oauth2", "0006_remove_oauth2provider_name"),
|
|
||||||
("authentik_core", "0011_provider_name_temp"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RenameField(
|
|
||||||
model_name="provider",
|
|
||||||
old_name="name_temp",
|
|
||||||
new_name="name",
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="token",
|
|
||||||
name="identifier",
|
|
||||||
field=models.TextField(default=""),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="token",
|
|
||||||
name="intent",
|
|
||||||
field=models.TextField(
|
|
||||||
choices=[
|
|
||||||
("verification", "Intent Verification"),
|
|
||||||
("api", "Intent Api"),
|
|
||||||
("recovery", "Intent Recovery"),
|
|
||||||
],
|
|
||||||
default="verification",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name="token",
|
|
||||||
unique_together={("identifier", "user")},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="token",
|
|
||||||
name="key",
|
|
||||||
field=models.TextField(default=authentik.core.models.default_token_key),
|
|
||||||
),
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name="token",
|
|
||||||
unique_together=set(),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="token",
|
|
||||||
name="identifier",
|
|
||||||
field=models.SlugField(max_length=255),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="token",
|
|
||||||
index=models.Index(fields=["key"], name="authentik_co_key_e45007_idx"),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="token",
|
|
||||||
index=models.Index(fields=["identifier"], name="authentik_co_identif_1a34a8_idx"),
|
|
||||||
),
|
|
||||||
migrations.RunPython(
|
|
||||||
code=set_default_token_key,
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="application",
|
|
||||||
name="meta_icon_url",
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="application",
|
|
||||||
name="meta_icon",
|
|
||||||
field=models.FileField(blank=True, default="", upload_to="application-icons/"),
|
|
||||||
),
|
|
||||||
migrations.RemoveIndex(
|
|
||||||
model_name="token",
|
|
||||||
name="authentik_co_key_e45007_idx",
|
|
||||||
),
|
|
||||||
migrations.RemoveIndex(
|
|
||||||
model_name="token",
|
|
||||||
name="authentik_co_identif_1a34a8_idx",
|
|
||||||
),
|
|
||||||
migrations.RenameField(
|
|
||||||
model_name="user",
|
|
||||||
old_name="pb_groups",
|
|
||||||
new_name="ak_groups",
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="token",
|
|
||||||
index=models.Index(fields=["identifier"], name="authentik_c_identif_d9d032_idx"),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="token",
|
|
||||||
index=models.Index(fields=["key"], name="authentik_c_key_f71355_idx"),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,209 +0,0 @@
|
|||||||
# Generated by Django 3.2.8 on 2021-10-10 16:12
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from os import environ
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.apps.registry import Apps
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|
||||||
from django.db.models import Count
|
|
||||||
|
|
||||||
import authentik.core.models
|
|
||||||
import authentik.lib.models
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
|
||||||
from django.core.cache import cache
|
|
||||||
|
|
||||||
session_keys = cache.keys(KEY_PREFIX + "*")
|
|
||||||
cache.delete_many(session_keys)
|
|
||||||
|
|
||||||
|
|
||||||
def fix_duplicates(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|
||||||
db_alias = schema_editor.connection.alias
|
|
||||||
Token = apps.get_model("authentik_core", "token")
|
|
||||||
identifiers = (
|
|
||||||
Token.objects.using(db_alias)
|
|
||||||
.values("identifier")
|
|
||||||
.annotate(identifier_count=Count("identifier"))
|
|
||||||
.filter(identifier_count__gt=1)
|
|
||||||
)
|
|
||||||
for ident in identifiers:
|
|
||||||
Token.objects.using(db_alias).filter(identifier=ident["identifier"]).delete()
|
|
||||||
|
|
||||||
|
|
||||||
def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|
||||||
# We have to use a direct import here, otherwise we get an object manager error
|
|
||||||
from authentik.core.models import Token, TokenIntents, User
|
|
||||||
|
|
||||||
db_alias = schema_editor.connection.alias
|
|
||||||
|
|
||||||
akadmin = User.objects.using(db_alias).filter(username="akadmin")
|
|
||||||
if not akadmin.exists():
|
|
||||||
return
|
|
||||||
if "AK_ADMIN_TOKEN" not in environ:
|
|
||||||
return
|
|
||||||
Token.objects.using(db_alias).create(
|
|
||||||
identifier="authentik-boostrap-token",
|
|
||||||
user=akadmin.first(),
|
|
||||||
intent=TokenIntents.INTENT_API,
|
|
||||||
expiring=False,
|
|
||||||
key=environ["AK_ADMIN_TOKEN"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
replaces = [
|
|
||||||
("authentik_core", "0018_auto_20210330_1345"),
|
|
||||||
("authentik_core", "0019_source_managed"),
|
|
||||||
("authentik_core", "0020_source_user_matching_mode"),
|
|
||||||
("authentik_core", "0021_alter_application_slug"),
|
|
||||||
("authentik_core", "0022_authenticatedsession"),
|
|
||||||
("authentik_core", "0023_alter_application_meta_launch_url"),
|
|
||||||
("authentik_core", "0024_alter_token_identifier"),
|
|
||||||
("authentik_core", "0025_alter_application_meta_icon"),
|
|
||||||
("authentik_core", "0026_alter_application_meta_icon"),
|
|
||||||
("authentik_core", "0027_bootstrap_token"),
|
|
||||||
("authentik_core", "0028_alter_token_intent"),
|
|
||||||
]
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_core", "0017_managed"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="token",
|
|
||||||
options={
|
|
||||||
"permissions": (("view_token_key", "View token's key"),),
|
|
||||||
"verbose_name": "Token",
|
|
||||||
"verbose_name_plural": "Tokens",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="source",
|
|
||||||
name="managed",
|
|
||||||
field=models.TextField(
|
|
||||||
default=None,
|
|
||||||
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.",
|
|
||||||
null=True,
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Managed by authentik",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="source",
|
|
||||||
name="user_matching_mode",
|
|
||||||
field=models.TextField(
|
|
||||||
choices=[
|
|
||||||
("identifier", "Use the source-specific identifier"),
|
|
||||||
(
|
|
||||||
"email_link",
|
|
||||||
"Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"email_deny",
|
|
||||||
"Use the user's email address, but deny enrollment when the email address already exists.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"username_link",
|
|
||||||
"Link to a user with identical username. Can have security implications when a username is used with another source.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"username_deny",
|
|
||||||
"Use the user's username, but deny enrollment when the username already exists.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
default="identifier",
|
|
||||||
help_text="How the source determines if an existing user should be authenticated or a new user enrolled.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="application",
|
|
||||||
name="slug",
|
|
||||||
field=models.SlugField(
|
|
||||||
help_text="Internal application name, used in URLs.", unique=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="AuthenticatedSession",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"expires",
|
|
||||||
models.DateTimeField(default=authentik.core.models.default_token_duration),
|
|
||||||
),
|
|
||||||
("expiring", models.BooleanField(default=True)),
|
|
||||||
("uuid", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
|
||||||
("session_key", models.CharField(max_length=40)),
|
|
||||||
("last_ip", models.TextField()),
|
|
||||||
("last_user_agent", models.TextField(blank=True)),
|
|
||||||
("last_used", models.DateTimeField(auto_now=True)),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"abstract": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.RunPython(
|
|
||||||
code=migrate_sessions,
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="application",
|
|
||||||
name="meta_launch_url",
|
|
||||||
field=models.TextField(
|
|
||||||
blank=True, default="", validators=[authentik.lib.models.DomainlessURLValidator()]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RunPython(
|
|
||||||
code=fix_duplicates,
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="token",
|
|
||||||
name="identifier",
|
|
||||||
field=models.SlugField(max_length=255, unique=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="application",
|
|
||||||
name="meta_icon",
|
|
||||||
field=models.FileField(default=None, null=True, upload_to="application-icons/"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="application",
|
|
||||||
name="meta_icon",
|
|
||||||
field=models.FileField(
|
|
||||||
default=None, max_length=500, null=True, upload_to="application-icons/"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="authenticatedsession",
|
|
||||||
options={
|
|
||||||
"verbose_name": "Authenticated Session",
|
|
||||||
"verbose_name_plural": "Authenticated Sessions",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.RunPython(
|
|
||||||
code=create_default_user_token,
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="token",
|
|
||||||
name="intent",
|
|
||||||
field=models.TextField(
|
|
||||||
choices=[
|
|
||||||
("verification", "Intent Verification"),
|
|
||||||
("api", "Intent Api"),
|
|
||||||
("recovery", "Intent Recovery"),
|
|
||||||
("app_password", "Intent App Password"),
|
|
||||||
],
|
|
||||||
default="verification",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -26,7 +26,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"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 address. Can have security implications when a username is used with another source.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"username_deny",
|
"username_deny",
|
||||||
|
@ -12,6 +12,7 @@ import authentik.core.models
|
|||||||
|
|
||||||
|
|
||||||
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
# Generated by Django 3.2.3 on 2021-06-02 21:51
|
# Generated by Django 3.2.3 on 2021-06-02 21:51
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
import authentik.lib.models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
@ -18,7 +17,7 @@ class Migration(migrations.Migration):
|
|||||||
field=models.TextField(
|
field=models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
validators=[authentik.lib.models.DomainlessURLValidator()],
|
validators=[django.core.validators.URLValidator()],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
"""authentik core models"""
|
"""authentik core models"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from hashlib import md5, sha256
|
from hashlib import md5, sha256
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional, Type
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from deepmerge import always_merger
|
from deepmerge import always_merger
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||||
|
from django.core import validators
|
||||||
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.templatetags.static import static
|
||||||
from django.utils.functional import SimpleLazyObject, cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -26,9 +26,10 @@ from structlog.stdlib import get_logger
|
|||||||
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.flows.models import Flow
|
||||||
from authentik.lib.config import CONFIG
|
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, SerializerModel
|
||||||
from authentik.lib.utils.http import get_client_ip
|
from authentik.lib.utils.http import get_client_ip
|
||||||
from authentik.managed.models import ManagedModel
|
from authentik.managed.models import ManagedModel
|
||||||
from authentik.policies.models import PolicyBindingModel
|
from authentik.policies.models import PolicyBindingModel
|
||||||
@ -38,9 +39,6 @@ USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
|
|||||||
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
|
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
|
||||||
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
|
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
|
||||||
USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec
|
USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec
|
||||||
USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username"
|
|
||||||
USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
|
|
||||||
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
|
|
||||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
||||||
|
|
||||||
GRAVATAR_URL = "https://secure.gravatar.com"
|
GRAVATAR_URL = "https://secure.gravatar.com"
|
||||||
@ -81,27 +79,6 @@ class Group(models.Model):
|
|||||||
)
|
)
|
||||||
attributes = models.JSONField(default=dict, blank=True)
|
attributes = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
def is_member(self, user: "User") -> bool:
|
|
||||||
"""Recursively check if `user` is member of us, or any parent."""
|
|
||||||
query = """
|
|
||||||
WITH RECURSIVE parents AS (
|
|
||||||
SELECT authentik_core_group.*, 0 AS relative_depth
|
|
||||||
FROM authentik_core_group
|
|
||||||
WHERE authentik_core_group.group_uuid = %s
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT authentik_core_group.*, parents.relative_depth - 1
|
|
||||||
FROM authentik_core_group,parents
|
|
||||||
WHERE authentik_core_group.parent_id = parents.group_uuid
|
|
||||||
)
|
|
||||||
SELECT group_uuid
|
|
||||||
FROM parents
|
|
||||||
GROUP BY group_uuid;
|
|
||||||
"""
|
|
||||||
groups = Group.objects.raw(query, [self.group_uuid])
|
|
||||||
return user.ak_groups.filter(pk__in=[group.pk for group in groups]).exists()
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Group {self.name}"
|
return f"Group {self.name}"
|
||||||
|
|
||||||
@ -162,22 +139,6 @@ class User(GuardianUserMixin, AbstractUser):
|
|||||||
self.password_change_date = now()
|
self.password_change_date = now()
|
||||||
return super().set_password(password)
|
return super().set_password(password)
|
||||||
|
|
||||||
def check_password(self, raw_password: str) -> bool:
|
|
||||||
"""
|
|
||||||
Return a boolean of whether the raw_password was correct. Handles
|
|
||||||
hashing formats behind the scenes.
|
|
||||||
|
|
||||||
Slightly changed version which doesn't send a signal for such internal hash upgrades
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setter(raw_password):
|
|
||||||
self.set_password(raw_password, signal=False)
|
|
||||||
# Password hash upgrades shouldn't be considered password changes.
|
|
||||||
self._password = None
|
|
||||||
self.save(update_fields=["password"])
|
|
||||||
|
|
||||||
return check_password(raw_password, self.password, setter)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uid(self) -> str:
|
def uid(self) -> str:
|
||||||
"""Generate a globall unique UID, based on the user ID and the hashed secret key"""
|
"""Generate a globall unique UID, based on the user ID and the hashed secret key"""
|
||||||
@ -190,7 +151,7 @@ class User(GuardianUserMixin, AbstractUser):
|
|||||||
if mode == "none":
|
if mode == "none":
|
||||||
return DEFAULT_AVATAR
|
return DEFAULT_AVATAR
|
||||||
# gravatar uses md5 for their URLs, so md5 can't be avoided
|
# gravatar uses md5 for their URLs, so md5 can't be avoided
|
||||||
mail_hash = md5(self.email.lower().encode("utf-8")).hexdigest() # nosec
|
mail_hash = md5(self.email.encode("utf-8")).hexdigest() # nosec
|
||||||
if mode == "gravatar":
|
if mode == "gravatar":
|
||||||
parameters = [
|
parameters = [
|
||||||
("s", "158"),
|
("s", "158"),
|
||||||
@ -220,7 +181,7 @@ class Provider(SerializerModel):
|
|||||||
name = models.TextField()
|
name = models.TextField()
|
||||||
|
|
||||||
authorization_flow = models.ForeignKey(
|
authorization_flow = models.ForeignKey(
|
||||||
"authentik_flows.Flow",
|
Flow,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
help_text=_("Flow used when authorizing this provider."),
|
help_text=_("Flow used when authorizing this provider."),
|
||||||
related_name="provider_authorization",
|
related_name="provider_authorization",
|
||||||
@ -242,7 +203,7 @@ class Provider(SerializerModel):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> Type[Serializer]:
|
||||||
"""Get serializer for this model"""
|
"""Get serializer for this model"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@ -262,7 +223,7 @@ class Application(PolicyBindingModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
meta_launch_url = models.TextField(
|
meta_launch_url = models.TextField(
|
||||||
default="", blank=True, validators=[DomainlessURLValidator()]
|
default="", blank=True, validators=[validators.URLValidator()]
|
||||||
)
|
)
|
||||||
# For template applications, this can be set to /static/authentik/applications/*
|
# For template applications, this can be set to /static/authentik/applications/*
|
||||||
meta_icon = models.FileField(
|
meta_icon = models.FileField(
|
||||||
@ -280,40 +241,23 @@ class Application(PolicyBindingModel):
|
|||||||
it is returned as-is"""
|
it is returned as-is"""
|
||||||
if not self.meta_icon:
|
if not self.meta_icon:
|
||||||
return None
|
return None
|
||||||
if "://" in self.meta_icon.name or self.meta_icon.name.startswith("/static"):
|
if self.meta_icon.name.startswith("http") or self.meta_icon.name.startswith("/static"):
|
||||||
return self.meta_icon.name
|
return self.meta_icon.name
|
||||||
return self.meta_icon.url
|
return self.meta_icon.url
|
||||||
|
|
||||||
def get_launch_url(self, user: Optional["User"] = None) -> Optional[str]:
|
def get_launch_url(self) -> Optional[str]:
|
||||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||||
url = None
|
|
||||||
if provider := self.get_provider():
|
|
||||||
url = provider.launch_url
|
|
||||||
if self.meta_launch_url:
|
if self.meta_launch_url:
|
||||||
url = self.meta_launch_url
|
return self.meta_launch_url
|
||||||
if user and url:
|
if self.provider:
|
||||||
if isinstance(user, SimpleLazyObject):
|
return self.get_provider().launch_url
|
||||||
user._setup()
|
return None
|
||||||
user = user._wrapped
|
|
||||||
try:
|
|
||||||
return url % user.__dict__
|
|
||||||
# pylint: disable=broad-except
|
|
||||||
except Exception as exc:
|
|
||||||
LOGGER.warning("Failed to format launch url", exc=exc)
|
|
||||||
return url
|
|
||||||
return url
|
|
||||||
|
|
||||||
def get_provider(self) -> Optional[Provider]:
|
def get_provider(self) -> Optional[Provider]:
|
||||||
"""Get casted provider instance"""
|
"""Get casted provider instance"""
|
||||||
if not self.provider:
|
if not self.provider:
|
||||||
return None
|
return None
|
||||||
# if the Application class has been cache, self.provider is set
|
return Provider.objects.get_subclass(pk=self.provider.pk)
|
||||||
# but doing a direct query lookup will fail.
|
|
||||||
# In that case, just return None
|
|
||||||
try:
|
|
||||||
return Provider.objects.get_subclass(pk=self.provider.pk)
|
|
||||||
except Provider.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@ -339,7 +283,7 @@ class SourceUserMatchingModes(models.TextChoices):
|
|||||||
)
|
)
|
||||||
USERNAME_LINK = "username_link", _(
|
USERNAME_LINK = "username_link", _(
|
||||||
(
|
(
|
||||||
"Link to a user with identical username. Can have security implications "
|
"Link to a user with identical username address. Can have security implications "
|
||||||
"when a username is used with another source."
|
"when a username is used with another source."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -358,7 +302,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
|
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
|
||||||
|
|
||||||
authentication_flow = models.ForeignKey(
|
authentication_flow = models.ForeignKey(
|
||||||
"authentik_flows.Flow",
|
Flow,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
default=None,
|
default=None,
|
||||||
@ -367,7 +311,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
related_name="source_authentication",
|
related_name="source_authentication",
|
||||||
)
|
)
|
||||||
enrollment_flow = models.ForeignKey(
|
enrollment_flow = models.ForeignKey(
|
||||||
"authentik_flows.Flow",
|
Flow,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
default=None,
|
default=None,
|
||||||
@ -394,11 +338,13 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
"""Return component used to edit this object"""
|
"""Return component used to edit this object"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def ui_login_button(self, request: HttpRequest) -> Optional[UILoginButton]:
|
@property
|
||||||
|
def ui_login_button(self) -> Optional[UILoginButton]:
|
||||||
"""If source uses a http-based flow, return UI Information about the login
|
"""If source uses a http-based flow, return UI Information about the login
|
||||||
button. If source doesn't use http-based flow, return None."""
|
button. If source doesn't use http-based flow, return None."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
def ui_user_settings(self) -> Optional[UserSettingSerializer]:
|
def ui_user_settings(self) -> Optional[UserSettingSerializer]:
|
||||||
"""Entrypoint to integrate with User settings. Can either return None if no
|
"""Entrypoint to integrate with User settings. Can either return None if no
|
||||||
user settings are available, or UserSettingSerializer."""
|
user settings are available, or UserSettingSerializer."""
|
||||||
@ -485,14 +431,6 @@ class Token(ManagedModel, ExpiringModel):
|
|||||||
"""Handler which is called when this object is expired."""
|
"""Handler which is called when this object is expired."""
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
|
||||||
if self.intent in [
|
|
||||||
TokenIntents.INTENT_RECOVERY,
|
|
||||||
TokenIntents.INTENT_VERIFICATION,
|
|
||||||
TokenIntents.INTENT_APP_PASSWORD,
|
|
||||||
]:
|
|
||||||
super().expire_action(*args, **kwargs)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.key = default_token_key()
|
self.key = default_token_key()
|
||||||
self.expires = default_token_duration()
|
self.expires = default_token_duration()
|
||||||
self.save(*args, **kwargs)
|
self.save(*args, **kwargs)
|
||||||
@ -534,7 +472,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> Type[Serializer]:
|
||||||
"""Get serializer for this model"""
|
"""Get serializer for this model"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
"""authentik core signals"""
|
"""authentik core signals"""
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Type
|
||||||
|
|
||||||
from django.apps import apps
|
|
||||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@ -12,8 +11,6 @@ from django.dispatch import receiver
|
|||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from prometheus_client import Gauge
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
from authentik.root.monitoring import monitoring_set
|
|
||||||
|
|
||||||
# Arguments: user: User, password: str
|
# Arguments: user: User, password: str
|
||||||
password_changed = Signal()
|
password_changed = Signal()
|
||||||
|
|
||||||
@ -23,17 +20,6 @@ if TYPE_CHECKING:
|
|||||||
from authentik.core.models import AuthenticatedSession, User
|
from authentik.core.models import AuthenticatedSession, User
|
||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_models(sender, **kwargs):
|
|
||||||
"""set models gauges"""
|
|
||||||
for model in apps.get_models():
|
|
||||||
GAUGE_MODELS.labels(
|
|
||||||
model_name=model._meta.model_name,
|
|
||||||
app=model._meta.app_label,
|
|
||||||
).set(model.objects.count())
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||||
@ -41,6 +27,11 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
|
|||||||
from authentik.core.api.applications import user_app_cache_key
|
from authentik.core.api.applications import user_app_cache_key
|
||||||
from authentik.core.models import Application
|
from authentik.core.models import Application
|
||||||
|
|
||||||
|
GAUGE_MODELS.labels(
|
||||||
|
model_name=sender._meta.model_name,
|
||||||
|
app=sender._meta.app_label,
|
||||||
|
).set(sender.objects.count())
|
||||||
|
|
||||||
if sender != Application:
|
if sender != Application:
|
||||||
return
|
return
|
||||||
if not created: # pragma: no cover
|
if not created: # pragma: no cover
|
||||||
@ -71,7 +62,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete)
|
@receiver(pre_delete)
|
||||||
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
def authenticated_session_delete(sender: Type[Model], instance: "AuthenticatedSession", **_):
|
||||||
"""Delete session when authenticated session is deleted"""
|
"""Delete session when authenticated session is deleted"""
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"""Source decision helper"""
|
"""Source decision helper"""
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional, Type
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
@ -14,7 +14,6 @@ from structlog.stdlib import get_logger
|
|||||||
from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection
|
from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection
|
||||||
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
|
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.flows.exceptions import FlowNonApplicableException
|
|
||||||
from authentik.flows.models import Flow, Stage, in_memory_stage
|
from authentik.flows.models import Flow, Stage, in_memory_stage
|
||||||
from authentik.flows.planner import (
|
from authentik.flows.planner import (
|
||||||
PLAN_CONTEXT_PENDING_USER,
|
PLAN_CONTEXT_PENDING_USER,
|
||||||
@ -23,10 +22,8 @@ from authentik.flows.planner import (
|
|||||||
PLAN_CONTEXT_SSO,
|
PLAN_CONTEXT_SSO,
|
||||||
FlowPlanner,
|
FlowPlanner,
|
||||||
)
|
)
|
||||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||||
from authentik.lib.utils.urls import redirect_with_qs
|
from authentik.lib.utils.urls import redirect_with_qs
|
||||||
from authentik.policies.denied import AccessDeniedResponse
|
|
||||||
from authentik.policies.types import PolicyResult
|
|
||||||
from authentik.policies.utils import delete_none_keys
|
from authentik.policies.utils import delete_none_keys
|
||||||
from authentik.stages.password import BACKEND_INBUILT
|
from authentik.stages.password import BACKEND_INBUILT
|
||||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||||
@ -53,10 +50,7 @@ class SourceFlowManager:
|
|||||||
|
|
||||||
identifier: str
|
identifier: str
|
||||||
|
|
||||||
connection_type: type[UserSourceConnection] = UserSourceConnection
|
connection_type: Type[UserSourceConnection] = UserSourceConnection
|
||||||
|
|
||||||
enroll_info: dict[str, Any]
|
|
||||||
policy_context: dict[str, Any]
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -70,7 +64,6 @@ class SourceFlowManager:
|
|||||||
self.identifier = identifier
|
self.identifier = identifier
|
||||||
self.enroll_info = enroll_info
|
self.enroll_info = enroll_info
|
||||||
self._logger = get_logger().bind(source=source, identifier=identifier)
|
self._logger = get_logger().bind(source=source, identifier=identifier)
|
||||||
self.policy_context = {}
|
|
||||||
|
|
||||||
# pylint: disable=too-many-return-statements
|
# pylint: disable=too-many-return-statements
|
||||||
def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
|
def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
|
||||||
@ -151,23 +144,20 @@ class SourceFlowManager:
|
|||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
self._logger.warning("failed to get action", exc=exc)
|
self._logger.warning("failed to get action", exc=exc)
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
self._logger.debug("get_action", action=action, connection=connection)
|
self._logger.debug("get_action() says", action=action, connection=connection)
|
||||||
try:
|
if connection:
|
||||||
if connection:
|
if action == Action.LINK:
|
||||||
if action == Action.LINK:
|
self._logger.debug("Linking existing user")
|
||||||
self._logger.debug("Linking existing user")
|
return self.handle_existing_user_link(connection)
|
||||||
return self.handle_existing_user_link(connection)
|
if action == Action.AUTH:
|
||||||
if action == Action.AUTH:
|
self._logger.debug("Handling auth user")
|
||||||
self._logger.debug("Handling auth user")
|
return self.handle_auth_user(connection)
|
||||||
return self.handle_auth_user(connection)
|
if action == Action.ENROLL:
|
||||||
if action == Action.ENROLL:
|
self._logger.debug("Handling enrollment of new user")
|
||||||
self._logger.debug("Handling enrollment of new user")
|
return self.handle_enroll(connection)
|
||||||
return self.handle_enroll(connection)
|
|
||||||
except FlowNonApplicableException as exc:
|
|
||||||
self._logger.warning("Flow non applicable", exc=exc)
|
|
||||||
return self.error_handler(exc, exc.policy_result)
|
|
||||||
# Default case, assume deny
|
# Default case, assume deny
|
||||||
error = (
|
messages.error(
|
||||||
|
self.request,
|
||||||
_(
|
_(
|
||||||
(
|
(
|
||||||
"Request to authenticate with %(source)s has been denied. Please authenticate "
|
"Request to authenticate with %(source)s has been denied. Please authenticate "
|
||||||
@ -176,17 +166,7 @@ class SourceFlowManager:
|
|||||||
% {"source": self.source.name}
|
% {"source": self.source.name}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return self.error_handler(error)
|
return redirect(reverse("authentik_core:root-redirect"))
|
||||||
|
|
||||||
def error_handler(
|
|
||||||
self, error: Exception, policy_result: Optional[PolicyResult] = None
|
|
||||||
) -> HttpResponse:
|
|
||||||
"""Handle any errors by returning an access denied stage"""
|
|
||||||
response = AccessDeniedResponse(self.request)
|
|
||||||
response.error_message = str(error)
|
|
||||||
if policy_result:
|
|
||||||
response.policy_result = policy_result
|
|
||||||
return response
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||||
@ -199,14 +179,12 @@ class SourceFlowManager:
|
|||||||
]
|
]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _handle_login_flow(
|
def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse:
|
||||||
self, flow: Flow, connection: UserSourceConnection, **kwargs
|
|
||||||
) -> HttpResponse:
|
|
||||||
"""Prepare Authentication Plan, redirect user FlowExecutor"""
|
"""Prepare Authentication Plan, redirect user FlowExecutor"""
|
||||||
# Ensure redirect is carried through when user was trying to
|
# Ensure redirect is carried through when user was trying to
|
||||||
# authorize application
|
# authorize application
|
||||||
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
||||||
NEXT_ARG_NAME, "authentik_core:if-user"
|
NEXT_ARG_NAME, "authentik_core:if-admin"
|
||||||
)
|
)
|
||||||
kwargs.update(
|
kwargs.update(
|
||||||
{
|
{
|
||||||
@ -215,10 +193,8 @@ class SourceFlowManager:
|
|||||||
PLAN_CONTEXT_SSO: True,
|
PLAN_CONTEXT_SSO: True,
|
||||||
PLAN_CONTEXT_SOURCE: self.source,
|
PLAN_CONTEXT_SOURCE: self.source,
|
||||||
PLAN_CONTEXT_REDIRECT: final_redirect,
|
PLAN_CONTEXT_REDIRECT: final_redirect,
|
||||||
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
kwargs.update(self.policy_context)
|
|
||||||
if not flow:
|
if not flow:
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
# We run the Flow planner here so we can pass the Pending user in the context
|
# We run the Flow planner here so we can pass the Pending user in the context
|
||||||
@ -244,7 +220,7 @@ class SourceFlowManager:
|
|||||||
_("Successfully authenticated with %(source)s!" % {"source": self.source.name}),
|
_("Successfully authenticated with %(source)s!" % {"source": self.source.name}),
|
||||||
)
|
)
|
||||||
flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
|
flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
|
||||||
return self._handle_login_flow(self.source.authentication_flow, connection, **flow_kwargs)
|
return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs)
|
||||||
|
|
||||||
def handle_existing_user_link(
|
def handle_existing_user_link(
|
||||||
self,
|
self,
|
||||||
@ -267,9 +243,9 @@ class SourceFlowManager:
|
|||||||
return self.handle_auth_user(connection)
|
return self.handle_auth_user(connection)
|
||||||
return redirect(
|
return redirect(
|
||||||
reverse(
|
reverse(
|
||||||
"authentik_core:if-user",
|
"authentik_core:if-admin",
|
||||||
)
|
)
|
||||||
+ f"#/settings;page-{self.source.slug}"
|
+ f"#/user;page-{self.source.slug}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle_enroll(
|
def handle_enroll(
|
||||||
@ -288,8 +264,8 @@ class SourceFlowManager:
|
|||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
return self._handle_login_flow(
|
return self._handle_login_flow(
|
||||||
self.source.enrollment_flow,
|
self.source.enrollment_flow,
|
||||||
connection,
|
|
||||||
**{
|
**{
|
||||||
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
|
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
|
||||||
|
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -28,7 +28,3 @@ class PostUserEnrollmentStage(StageView):
|
|||||||
source=connection.source,
|
source=connection.source,
|
||||||
).from_http(self.request)
|
).from_http(self.request)
|
||||||
return self.executor.stage_ok()
|
return self.executor.stage_ok()
|
||||||
|
|
||||||
def post(self, request: HttpRequest) -> HttpResponse:
|
|
||||||
"""Wrapper for post requests"""
|
|
||||||
return self.get(request)
|
|
||||||
|
@ -1,23 +1,28 @@
|
|||||||
"""authentik core tasks"""
|
"""authentik core tasks"""
|
||||||
|
from datetime import datetime
|
||||||
|
from io import StringIO
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
from boto3.exceptions import Boto3Error
|
||||||
|
from botocore.exceptions import BotoCoreError, ClientError
|
||||||
|
from dbbackup.db.exceptions import CommandConnectorError
|
||||||
|
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
|
from django.core import management
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession, ExpiringModel
|
from authentik.core.models import AuthenticatedSession, ExpiringModel
|
||||||
from authentik.events.monitored_tasks import (
|
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||||
MonitoredTask,
|
from authentik.lib.config import CONFIG
|
||||||
TaskResult,
|
|
||||||
TaskResultStatus,
|
|
||||||
prefill_task,
|
|
||||||
)
|
|
||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||||
@prefill_task
|
|
||||||
def clean_expired_models(self: MonitoredTask):
|
def clean_expired_models(self: MonitoredTask):
|
||||||
"""Remove expired objects"""
|
"""Remove expired objects"""
|
||||||
messages = []
|
messages = []
|
||||||
@ -42,3 +47,46 @@ def clean_expired_models(self: MonitoredTask):
|
|||||||
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
|
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
|
||||||
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
|
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
|
||||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
|
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||||
|
def backup_database(self: MonitoredTask): # pragma: no cover
|
||||||
|
"""Database backup"""
|
||||||
|
self.result_timeout_hours = 25
|
||||||
|
if SERVICE_HOST_ENV_NAME in environ and not CONFIG.y("postgresql.s3_backup"):
|
||||||
|
LOGGER.info("Running in k8s and s3 backups are not configured, skipping")
|
||||||
|
self.set_status(
|
||||||
|
TaskResult(
|
||||||
|
TaskResultStatus.WARNING,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"Skipping backup as authentik is running in Kubernetes "
|
||||||
|
"without S3 backups configured."
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
start = datetime.now()
|
||||||
|
out = StringIO()
|
||||||
|
management.call_command("dbbackup", quiet=True, stdout=out)
|
||||||
|
self.set_status(
|
||||||
|
TaskResult(
|
||||||
|
TaskResultStatus.SUCCESSFUL,
|
||||||
|
[
|
||||||
|
f"Successfully finished database backup {naturaltime(start)} {out.getvalue()}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LOGGER.info("Successfully backed up database.")
|
||||||
|
except (
|
||||||
|
IOError,
|
||||||
|
BotoCoreError,
|
||||||
|
ClientError,
|
||||||
|
Boto3Error,
|
||||||
|
PermissionError,
|
||||||
|
CommandConnectorError,
|
||||||
|
ValueError,
|
||||||
|
) as exc:
|
||||||
|
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||||
|
@ -8,19 +8,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
<title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title>
|
<title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title>
|
||||||
<link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}">
|
<link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}?v={{ ak_version }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}?v={{ ak_version }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}?v={{ ak_version }}">
|
||||||
{% 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' %}?v={{ ak_version }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}">
|
<script src="{% static 'dist/poly.js' %}?v={{ ak_version }}" type="module"></script>
|
||||||
<script src="{% static 'dist/poly.js' %}" type="module"></script>
|
<script>window["polymerSkipLoadingFontRoboto"] = true;</script>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<meta name="sentry-trace" content="{{ sentry_trace }}" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
@ -4,14 +4,12 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script src="{% static 'dist/admin/AdminInterface.js' %}" type="module"></script>
|
<script src="{% static 'dist/AdminInterface.js' %}?v={{ ak_version }}" type="module"></script>
|
||||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
|
|
||||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<ak-message-container data-refresh-on-locale="true"></ak-message-container>
|
<ak-message-container></ak-message-container>
|
||||||
<ak-interface-admin data-refresh-on-locale="true">
|
<ak-interface-admin>
|
||||||
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
||||||
<div class="pf-c-empty-state" style="height: 100vh;">
|
<div class="pf-c-empty-state" style="height: 100vh;">
|
||||||
<div class="pf-c-empty-state__content">
|
<div class="pf-c-empty-state__content">
|
||||||
|
@ -21,7 +21,7 @@ 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">{% trans 'Go back to overview' %}</a>
|
<a id="ak-back-home" href="{% url 'authentik_core:if-admin' %}" class="pf-c-button pf-m-primary">{% trans 'Go back to overview' %}</a>
|
||||||
|
|
||||||
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">{% trans 'Log out of authentik' %}</a>
|
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">{% trans 'Log out of authentik' %}</a>
|
||||||
|
|
||||||
|
@ -4,14 +4,13 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
{{ block.super }}
|
{% if flow.compatibility_mode %}
|
||||||
{% if flow.compatibility_mode and not inspector %}
|
|
||||||
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script src="{% static 'dist/flow/FlowInterface.js' %}" type="module"></script>
|
<script src="{% static 'dist/FlowInterface.js' %}?v={{ ak_version }}" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.pf-c-background-image::before {
|
.pf-c-background-image::before {
|
||||||
--ak-flow-background: url("{{ flow.background_url }}");
|
--ak-flow-background: url("{{ flow.background_url }}");
|
||||||
@ -20,8 +19,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<ak-message-container data-refresh-on-locale="true"></ak-message-container>
|
<ak-message-container></ak-message-container>
|
||||||
<ak-flow-executor data-refresh-on-locale="true">
|
<ak-flow-executor>
|
||||||
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
||||||
<div class="pf-c-empty-state" style="height: 100vh;">
|
<div class="pf-c-empty-state" style="height: 100vh;">
|
||||||
<div class="pf-c-empty-state__content">
|
<div class="pf-c-empty-state__content">
|
||||||
|
@ -1,30 +0,0 @@
|
|||||||
{% extends "base/skeleton.html" %}
|
|
||||||
|
|
||||||
{% load static %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<script src="{% static 'dist/user/UserInterface.js' %}" type="module"></script>
|
|
||||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
|
|
||||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<ak-message-container data-refresh-on-locale="true"></ak-message-container>
|
|
||||||
<ak-interface-user data-refresh-on-locale="true">
|
|
||||||
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
|
||||||
<div class="pf-c-empty-state" style="height: 100vh;">
|
|
||||||
<div class="pf-c-empty-state__content">
|
|
||||||
<span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}">
|
|
||||||
<span class="pf-c-spinner__clipper"></span>
|
|
||||||
<span class="pf-c-spinner__lead-ball"></span>
|
|
||||||
<span class="pf-c-spinner__tail-ball"></span>
|
|
||||||
</span>
|
|
||||||
<h1 class="pf-c-title pf-m-lg">
|
|
||||||
{% trans "Loading..." %}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</ak-interface-user>
|
|
||||||
{% endblock %}
|
|
@ -4,7 +4,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}?v={{ ak_version }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
@ -61,7 +61,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if tenant.branding_title != "authentik" %}
|
{% if tenant.branding_title != "authentik" %}
|
||||||
<li>
|
<li>
|
||||||
<a href="https://goauthentik.io?utm_source=authentik">
|
<a href="https://goauthentik.io">
|
||||||
{% trans 'Powered by authentik' %}
|
{% trans 'Powered by authentik' %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -1,34 +1,19 @@
|
|||||||
"""Test Applications API"""
|
"""Test Applications API"""
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.encoding import force_str
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import Application
|
from authentik.core.models import Application, User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
|
||||||
from authentik.flows.models import Flow
|
|
||||||
from authentik.policies.dummy.models import DummyPolicy
|
from authentik.policies.dummy.models import DummyPolicy
|
||||||
from authentik.policies.models import PolicyBinding
|
from authentik.policies.models import PolicyBinding
|
||||||
from authentik.providers.oauth2.models import OAuth2Provider
|
|
||||||
|
|
||||||
|
|
||||||
class TestApplicationsAPI(APITestCase):
|
class TestApplicationsAPI(APITestCase):
|
||||||
"""Test applications API"""
|
"""Test applications API"""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user = create_test_admin_user()
|
self.user = User.objects.get(username="akadmin")
|
||||||
self.provider = OAuth2Provider.objects.create(
|
self.allowed = Application.objects.create(name="allowed", slug="allowed")
|
||||||
name="test",
|
|
||||||
redirect_uris="http://some-other-domain",
|
|
||||||
authorization_flow=Flow.objects.create(
|
|
||||||
name="test",
|
|
||||||
slug="test",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.allowed = Application.objects.create(
|
|
||||||
name="allowed",
|
|
||||||
slug="allowed",
|
|
||||||
meta_launch_url="https://goauthentik.io/%(username)s",
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
self.denied = Application.objects.create(name="denied", slug="denied")
|
self.denied = Application.objects.create(name="denied", slug="denied")
|
||||||
PolicyBinding.objects.create(
|
PolicyBinding.objects.create(
|
||||||
target=self.denied,
|
target=self.denied,
|
||||||
@ -46,7 +31,7 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertJSONEqual(response.content.decode(), {"messages": [], "passing": True})
|
self.assertJSONEqual(force_str(response.content), {"messages": [], "passing": True})
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse(
|
reverse(
|
||||||
"authentik_api:application-check-access",
|
"authentik_api:application-check-access",
|
||||||
@ -54,14 +39,14 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertJSONEqual(response.content.decode(), {"messages": ["dummy"], "passing": False})
|
self.assertJSONEqual(force_str(response.content), {"messages": ["dummy"], "passing": False})
|
||||||
|
|
||||||
def test_list(self):
|
def test_list(self):
|
||||||
"""Test list operation without superuser_full_list"""
|
"""Test list operation without superuser_full_list"""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
response = self.client.get(reverse("authentik_api:application-list"))
|
response = self.client.get(reverse("authentik_api:application-list"))
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
response.content.decode(),
|
force_str(response.content),
|
||||||
{
|
{
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": 0,
|
"next": 0,
|
||||||
@ -77,21 +62,10 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
"pk": str(self.allowed.pk),
|
"pk": str(self.allowed.pk),
|
||||||
"name": "allowed",
|
"name": "allowed",
|
||||||
"slug": "allowed",
|
"slug": "allowed",
|
||||||
"provider": self.provider.pk,
|
"provider": None,
|
||||||
"provider_obj": {
|
"provider_obj": None,
|
||||||
"assigned_application_name": "allowed",
|
"launch_url": None,
|
||||||
"assigned_application_slug": "allowed",
|
"meta_launch_url": "",
|
||||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
|
||||||
"component": "ak-provider-oauth2-form",
|
|
||||||
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
|
|
||||||
"name": self.provider.name,
|
|
||||||
"pk": self.provider.pk,
|
|
||||||
"property_mappings": [],
|
|
||||||
"verbose_name": "OAuth2/OpenID Provider",
|
|
||||||
"verbose_name_plural": "OAuth2/OpenID Providers",
|
|
||||||
},
|
|
||||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
|
||||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
|
||||||
"meta_icon": None,
|
"meta_icon": None,
|
||||||
"meta_description": "",
|
"meta_description": "",
|
||||||
"meta_publisher": "",
|
"meta_publisher": "",
|
||||||
@ -108,7 +82,7 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
reverse("authentik_api:application-list") + "?superuser_full_list=true"
|
reverse("authentik_api:application-list") + "?superuser_full_list=true"
|
||||||
)
|
)
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
response.content.decode(),
|
force_str(response.content),
|
||||||
{
|
{
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": 0,
|
"next": 0,
|
||||||
@ -124,21 +98,10 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
"pk": str(self.allowed.pk),
|
"pk": str(self.allowed.pk),
|
||||||
"name": "allowed",
|
"name": "allowed",
|
||||||
"slug": "allowed",
|
"slug": "allowed",
|
||||||
"provider": self.provider.pk,
|
"provider": None,
|
||||||
"provider_obj": {
|
"provider_obj": None,
|
||||||
"assigned_application_name": "allowed",
|
"launch_url": None,
|
||||||
"assigned_application_slug": "allowed",
|
"meta_launch_url": "",
|
||||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
|
||||||
"component": "ak-provider-oauth2-form",
|
|
||||||
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
|
|
||||||
"name": self.provider.name,
|
|
||||||
"pk": self.provider.pk,
|
|
||||||
"property_mappings": [],
|
|
||||||
"verbose_name": "OAuth2/OpenID Provider",
|
|
||||||
"verbose_name_plural": "OAuth2/OpenID Providers",
|
|
||||||
},
|
|
||||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
|
||||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
|
||||||
"meta_icon": None,
|
"meta_icon": None,
|
||||||
"meta_description": "",
|
"meta_description": "",
|
||||||
"meta_publisher": "",
|
"meta_publisher": "",
|
||||||
|
@ -1,67 +0,0 @@
|
|||||||
"""Test Applications API"""
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from authentik.core.models import Application
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_tenant
|
|
||||||
from authentik.flows.models import Flow, FlowDesignation
|
|
||||||
from authentik.flows.tests import FlowTestCase
|
|
||||||
from authentik.tenants.models import Tenant
|
|
||||||
|
|
||||||
|
|
||||||
class TestApplicationsViews(FlowTestCase):
|
|
||||||
"""Test applications Views"""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.user = create_test_admin_user()
|
|
||||||
self.allowed = Application.objects.create(
|
|
||||||
name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_check_redirect(self):
|
|
||||||
"""Test redirect"""
|
|
||||||
empty_flow = Flow.objects.create(
|
|
||||||
name="foo",
|
|
||||||
slug="foo",
|
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
|
||||||
)
|
|
||||||
tenant: Tenant = create_test_tenant()
|
|
||||||
tenant.flow_authentication = empty_flow
|
|
||||||
tenant.save()
|
|
||||||
response = self.client.get(
|
|
||||||
reverse(
|
|
||||||
"authentik_core:application-launch",
|
|
||||||
kwargs={"application_slug": self.allowed.slug},
|
|
||||||
),
|
|
||||||
follow=True,
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
with patch(
|
|
||||||
"authentik.flows.stage.StageView.get_pending_user", MagicMock(return_value=self.user)
|
|
||||||
):
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": empty_flow.slug})
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertStageRedirects(response, f"https://goauthentik.io/{self.user.username}")
|
|
||||||
|
|
||||||
def test_check_redirect_auth(self):
|
|
||||||
"""Test redirect"""
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
empty_flow = Flow.objects.create(
|
|
||||||
name="foo",
|
|
||||||
slug="foo",
|
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
|
||||||
)
|
|
||||||
tenant: Tenant = create_test_tenant()
|
|
||||||
tenant.flow_authentication = empty_flow
|
|
||||||
tenant.save()
|
|
||||||
response = self.client.get(
|
|
||||||
reverse(
|
|
||||||
"authentik_core:application-launch",
|
|
||||||
kwargs={"application_slug": self.allowed.slug},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, f"https://goauthentik.io/{self.user.username}")
|
|
@ -2,10 +2,10 @@
|
|||||||
from json import loads
|
from json import loads
|
||||||
|
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
|
from django.utils.encoding import force_str
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuthenticatedSessionsAPI(APITestCase):
|
class TestAuthenticatedSessionsAPI(APITestCase):
|
||||||
@ -13,7 +13,7 @@ class TestAuthenticatedSessionsAPI(APITestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = create_test_admin_user()
|
self.user = User.objects.get(username="akadmin")
|
||||||
self.other_user = User.objects.create(username="normal-user")
|
self.other_user = User.objects.create(username="normal-user")
|
||||||
|
|
||||||
def test_list(self):
|
def test_list(self):
|
||||||
@ -27,5 +27,5 @@ class TestAuthenticatedSessionsAPI(APITestCase):
|
|||||||
self.client.force_login(self.other_user)
|
self.client.force_login(self.other_user)
|
||||||
response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
|
response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
body = loads(response.content.decode())
|
body = loads(force_str(response.content))
|
||||||
self.assertEqual(body["pagination"]["count"], 1)
|
self.assertEqual(body["pagination"]["count"], 1)
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
"""group tests"""
|
|
||||||
from django.test.testcases import TestCase
|
|
||||||
|
|
||||||
from authentik.core.models import Group, User
|
|
||||||
|
|
||||||
|
|
||||||
class TestGroups(TestCase):
|
|
||||||
"""Test group membership"""
|
|
||||||
|
|
||||||
def test_group_membership_simple(self):
|
|
||||||
"""Test simple membership"""
|
|
||||||
user = User.objects.create(username="user")
|
|
||||||
user2 = User.objects.create(username="user2")
|
|
||||||
group = Group.objects.create(name="group")
|
|
||||||
group.users.add(user)
|
|
||||||
self.assertTrue(group.is_member(user))
|
|
||||||
self.assertFalse(group.is_member(user2))
|
|
||||||
|
|
||||||
def test_group_membership_parent(self):
|
|
||||||
"""Test parent membership"""
|
|
||||||
user = User.objects.create(username="user")
|
|
||||||
user2 = User.objects.create(username="user2")
|
|
||||||
first = Group.objects.create(name="first")
|
|
||||||
second = Group.objects.create(name="second", parent=first)
|
|
||||||
second.users.add(user)
|
|
||||||
self.assertTrue(first.is_member(user))
|
|
||||||
self.assertFalse(first.is_member(user2))
|
|
||||||
|
|
||||||
def test_group_membership_parent_extra(self):
|
|
||||||
"""Test parent membership"""
|
|
||||||
user = User.objects.create(username="user")
|
|
||||||
user2 = User.objects.create(username="user2")
|
|
||||||
first = Group.objects.create(name="first")
|
|
||||||
second = Group.objects.create(name="second", parent=first)
|
|
||||||
third = Group.objects.create(name="third", parent=second)
|
|
||||||
second.users.add(user)
|
|
||||||
self.assertTrue(first.is_member(user))
|
|
||||||
self.assertFalse(first.is_member(user2))
|
|
||||||
self.assertFalse(third.is_member(user))
|
|
||||||
self.assertFalse(third.is_member(user2))
|
|
@ -5,7 +5,6 @@ from django.test.testcases import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
|
||||||
|
|
||||||
|
|
||||||
class TestImpersonation(TestCase):
|
class TestImpersonation(TestCase):
|
||||||
@ -14,14 +13,14 @@ class TestImpersonation(TestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.other_user = User.objects.create(username="to-impersonate")
|
self.other_user = User.objects.create(username="to-impersonate")
|
||||||
self.user = create_test_admin_user()
|
self.akadmin = User.objects.get(username="akadmin")
|
||||||
|
|
||||||
def test_impersonate_simple(self):
|
def test_impersonate_simple(self):
|
||||||
"""test simple impersonation and un-impersonation"""
|
"""test simple impersonation and un-impersonation"""
|
||||||
# test with an inactive user to ensure that still works
|
# test with an inactive user to ensure that still works
|
||||||
self.other_user.is_active = False
|
self.other_user.is_active = False
|
||||||
self.other_user.save()
|
self.other_user.save()
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.akadmin)
|
||||||
|
|
||||||
self.client.get(
|
self.client.get(
|
||||||
reverse(
|
reverse(
|
||||||
@ -33,13 +32,13 @@ class TestImpersonation(TestCase):
|
|||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
response_body = loads(response.content.decode())
|
response_body = loads(response.content.decode())
|
||||||
self.assertEqual(response_body["user"]["username"], self.other_user.username)
|
self.assertEqual(response_body["user"]["username"], self.other_user.username)
|
||||||
self.assertEqual(response_body["original"]["username"], self.user.username)
|
self.assertEqual(response_body["original"]["username"], self.akadmin.username)
|
||||||
|
|
||||||
self.client.get(reverse("authentik_core:impersonate-end"))
|
self.client.get(reverse("authentik_core:impersonate-end"))
|
||||||
|
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
response_body = loads(response.content.decode())
|
response_body = loads(response.content.decode())
|
||||||
self.assertEqual(response_body["user"]["username"], self.user.username)
|
self.assertEqual(response_body["user"]["username"], self.akadmin.username)
|
||||||
self.assertNotIn("original", response_body)
|
self.assertNotIn("original", response_body)
|
||||||
|
|
||||||
def test_impersonate_denied(self):
|
def test_impersonate_denied(self):
|
||||||
@ -47,7 +46,7 @@ class TestImpersonation(TestCase):
|
|||||||
self.client.force_login(self.other_user)
|
self.client.force_login(self.other_user)
|
||||||
|
|
||||||
self.client.get(
|
self.client.get(
|
||||||
reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk})
|
reverse("authentik_core:impersonate-init", kwargs={"user_id": self.akadmin.pk})
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
@ -59,4 +58,4 @@ class TestImpersonation(TestCase):
|
|||||||
self.client.force_login(self.other_user)
|
self.client.force_login(self.other_user)
|
||||||
|
|
||||||
response = self.client.get(reverse("authentik_core:impersonate-end"))
|
response = self.client.get(reverse("authentik_core:impersonate-end"))
|
||||||
self.assertRedirects(response, reverse("authentik_core:if-user"))
|
self.assertRedirects(response, reverse("authentik_core:if-admin"))
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
"""authentik core models tests"""
|
"""authentik core models tests"""
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import Callable
|
from typing import Callable, Type
|
||||||
|
|
||||||
from django.test import RequestFactory, TestCase
|
from django.test import TestCase
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
@ -27,12 +27,9 @@ class TestModels(TestCase):
|
|||||||
self.assertFalse(token.is_expired)
|
self.assertFalse(token.is_expired)
|
||||||
|
|
||||||
|
|
||||||
def source_tester_factory(test_model: type[Stage]) -> Callable:
|
def source_tester_factory(test_model: Type[Stage]) -> Callable:
|
||||||
"""Test source"""
|
"""Test source"""
|
||||||
|
|
||||||
factory = RequestFactory()
|
|
||||||
request = factory.get("/")
|
|
||||||
|
|
||||||
def tester(self: TestModels):
|
def tester(self: TestModels):
|
||||||
model_class = None
|
model_class = None
|
||||||
if test_model._meta.abstract:
|
if test_model._meta.abstract:
|
||||||
@ -41,18 +38,18 @@ def source_tester_factory(test_model: type[Stage]) -> Callable:
|
|||||||
model_class = test_model()
|
model_class = test_model()
|
||||||
model_class.slug = "test"
|
model_class.slug = "test"
|
||||||
self.assertIsNotNone(model_class.component)
|
self.assertIsNotNone(model_class.component)
|
||||||
_ = model_class.ui_login_button(request)
|
_ = model_class.ui_login_button
|
||||||
_ = model_class.ui_user_settings()
|
_ = model_class.ui_user_settings
|
||||||
|
|
||||||
return tester
|
return tester
|
||||||
|
|
||||||
|
|
||||||
def provider_tester_factory(test_model: type[Stage]) -> Callable:
|
def provider_tester_factory(test_model: Type[Stage]) -> Callable:
|
||||||
"""Test provider"""
|
"""Test provider"""
|
||||||
|
|
||||||
def tester(self: TestModels):
|
def tester(self: TestModels):
|
||||||
model_class = None
|
model_class = None
|
||||||
if test_model._meta.abstract: # pragma: no cover
|
if test_model._meta.abstract:
|
||||||
model_class = test_model.__bases__[0]()
|
model_class = test_model.__bases__[0]()
|
||||||
else:
|
else:
|
||||||
model_class = test_model()
|
model_class = test_model()
|
||||||
@ -62,6 +59,6 @@ def provider_tester_factory(test_model: type[Stage]) -> Callable:
|
|||||||
|
|
||||||
|
|
||||||
for model in all_subclasses(Source):
|
for model in all_subclasses(Source):
|
||||||
setattr(TestModels, f"test_source_{model.__name__}", source_tester_factory(model))
|
setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model))
|
||||||
for model in all_subclasses(Provider):
|
for model in all_subclasses(Provider):
|
||||||
setattr(TestModels, f"test_provider_{model.__name__}", provider_tester_factory(model))
|
setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model))
|
||||||
|
@ -6,8 +6,7 @@ from rest_framework.serializers import ValidationError
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.api.propertymappings import PropertyMappingSerializer
|
from authentik.core.api.propertymappings import PropertyMappingSerializer
|
||||||
from authentik.core.models import PropertyMapping
|
from authentik.core.models import PropertyMapping, User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
|
||||||
|
|
||||||
|
|
||||||
class TestPropertyMappingAPI(APITestCase):
|
class TestPropertyMappingAPI(APITestCase):
|
||||||
@ -18,7 +17,7 @@ class TestPropertyMappingAPI(APITestCase):
|
|||||||
self.mapping = PropertyMapping.objects.create(
|
self.mapping = PropertyMapping.objects.create(
|
||||||
name="dummy", expression="""return {'foo': 'bar'}"""
|
name="dummy", expression="""return {'foo': 'bar'}"""
|
||||||
)
|
)
|
||||||
self.user = create_test_admin_user()
|
self.user = User.objects.get(username="akadmin")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
def test_test_call(self):
|
def test_test_call(self):
|
||||||
@ -41,7 +40,7 @@ class TestPropertyMappingAPI(APITestCase):
|
|||||||
expr = "return True"
|
expr = "return True"
|
||||||
self.assertEqual(PropertyMappingSerializer().validate_expression(expr), expr)
|
self.assertEqual(PropertyMappingSerializer().validate_expression(expr), expr)
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
PropertyMappingSerializer().validate_expression("/")
|
print(PropertyMappingSerializer().validate_expression("/"))
|
||||||
|
|
||||||
def test_types(self):
|
def test_types(self):
|
||||||
"""Test PropertyMappigns's types endpoint"""
|
"""Test PropertyMappigns's types endpoint"""
|
||||||
|
@ -2,8 +2,7 @@
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import PropertyMapping
|
from authentik.core.models import PropertyMapping, User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
|
||||||
|
|
||||||
|
|
||||||
class TestProvidersAPI(APITestCase):
|
class TestProvidersAPI(APITestCase):
|
||||||
@ -14,7 +13,7 @@ class TestProvidersAPI(APITestCase):
|
|||||||
self.mapping = PropertyMapping.objects.create(
|
self.mapping = PropertyMapping.objects.create(
|
||||||
name="dummy", expression="""return {'foo': 'bar'}"""
|
name="dummy", expression="""return {'foo': 'bar'}"""
|
||||||
)
|
)
|
||||||
self.user = create_test_admin_user()
|
self.user = User.objects.get(username="akadmin")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
def test_types(self):
|
def test_types(self):
|
||||||
|
@ -6,12 +6,8 @@ from guardian.utils import get_anonymous_user
|
|||||||
|
|
||||||
from authentik.core.models import SourceUserMatchingModes, User
|
from authentik.core.models import SourceUserMatchingModes, User
|
||||||
from authentik.core.sources.flow_manager import Action
|
from authentik.core.sources.flow_manager import Action
|
||||||
from authentik.flows.models import Flow, FlowDesignation
|
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.lib.tests.utils import get_request
|
from authentik.lib.tests.utils import get_request
|
||||||
from authentik.policies.denied import AccessDeniedResponse
|
|
||||||
from authentik.policies.expression.models import ExpressionPolicy
|
|
||||||
from authentik.policies.models import PolicyBinding
|
|
||||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||||
from authentik.sources.oauth.views.callback import OAuthSourceFlowManager
|
from authentik.sources.oauth.views.callback import OAuthSourceFlowManager
|
||||||
|
|
||||||
@ -21,7 +17,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.objects.create(name="test")
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
self.identifier = generate_id()
|
self.identifier = generate_id()
|
||||||
|
|
||||||
@ -147,34 +143,3 @@ class TestSourceFlowManager(TestCase):
|
|||||||
action, _ = flow_manager.get_action()
|
action, _ = flow_manager.get_action()
|
||||||
self.assertEqual(action, Action.ENROLL)
|
self.assertEqual(action, Action.ENROLL)
|
||||||
flow_manager.get_flow()
|
flow_manager.get_flow()
|
||||||
|
|
||||||
def test_error_non_applicable_flow(self):
|
|
||||||
"""Test error handling when a source selected flow is non-applicable due to a policy"""
|
|
||||||
self.source.user_matching_mode = SourceUserMatchingModes.USERNAME_LINK
|
|
||||||
|
|
||||||
flow = Flow.objects.create(
|
|
||||||
name="test", slug="test", title="test", designation=FlowDesignation.ENROLLMENT
|
|
||||||
)
|
|
||||||
policy = ExpressionPolicy.objects.create(
|
|
||||||
name="false", expression="""ak_message("foo");return False"""
|
|
||||||
)
|
|
||||||
PolicyBinding.objects.create(
|
|
||||||
policy=policy,
|
|
||||||
target=flow,
|
|
||||||
order=0,
|
|
||||||
)
|
|
||||||
self.source.enrollment_flow = flow
|
|
||||||
self.source.save()
|
|
||||||
|
|
||||||
flow_manager = OAuthSourceFlowManager(
|
|
||||||
self.source,
|
|
||||||
get_request("/", user=AnonymousUser()),
|
|
||||||
self.identifier,
|
|
||||||
{"username": "foo"},
|
|
||||||
)
|
|
||||||
action, _ = flow_manager.get_action()
|
|
||||||
self.assertEqual(action, Action.ENROLL)
|
|
||||||
response = flow_manager.get_flow()
|
|
||||||
self.assertIsInstance(response, AccessDeniedResponse)
|
|
||||||
# pylint: disable=no-member
|
|
||||||
self.assertEqual(response.error_message, "foo")
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""Test token API"""
|
"""Test token API"""
|
||||||
from json import loads
|
|
||||||
|
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
@ -8,7 +6,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.tasks import clean_expired_models
|
from authentik.core.tasks import clean_expired_models
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
|
||||||
|
|
||||||
|
|
||||||
class TestTokenAPI(APITestCase):
|
class TestTokenAPI(APITestCase):
|
||||||
@ -16,8 +13,7 @@ class TestTokenAPI(APITestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = User.objects.create(username="testuser")
|
self.user = User.objects.get(username="akadmin")
|
||||||
self.admin = create_test_admin_user()
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
def test_token_create(self):
|
def test_token_create(self):
|
||||||
@ -30,7 +26,6 @@ class TestTokenAPI(APITestCase):
|
|||||||
self.assertEqual(token.user, self.user)
|
self.assertEqual(token.user, self.user)
|
||||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||||
self.assertEqual(token.expiring, True)
|
self.assertEqual(token.expiring, True)
|
||||||
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
|
||||||
|
|
||||||
def test_token_create_invalid(self):
|
def test_token_create_invalid(self):
|
||||||
"""Test token creation endpoint (invalid data)"""
|
"""Test token creation endpoint (invalid data)"""
|
||||||
@ -55,36 +50,8 @@ class TestTokenAPI(APITestCase):
|
|||||||
|
|
||||||
def test_token_expire(self):
|
def test_token_expire(self):
|
||||||
"""Test Token expire task"""
|
"""Test Token expire task"""
|
||||||
token: Token = Token.objects.create(
|
token: Token = Token.objects.create(expires=now(), user=get_anonymous_user())
|
||||||
expires=now(), user=get_anonymous_user(), intent=TokenIntents.INTENT_API
|
|
||||||
)
|
|
||||||
key = token.key
|
key = token.key
|
||||||
clean_expired_models.delay().get()
|
clean_expired_models.delay().get()
|
||||||
token.refresh_from_db()
|
token.refresh_from_db()
|
||||||
self.assertNotEqual(key, token.key)
|
self.assertNotEqual(key, token.key)
|
||||||
|
|
||||||
def test_list(self):
|
|
||||||
"""Test Token List (Test normal authentication)"""
|
|
||||||
token_should: Token = Token.objects.create(
|
|
||||||
identifier="test", expiring=False, user=self.user
|
|
||||||
)
|
|
||||||
Token.objects.create(identifier="test-2", expiring=False, user=get_anonymous_user())
|
|
||||||
response = self.client.get(reverse(("authentik_api:token-list")))
|
|
||||||
body = loads(response.content)
|
|
||||||
self.assertEqual(len(body["results"]), 1)
|
|
||||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
|
||||||
|
|
||||||
def test_list_admin(self):
|
|
||||||
"""Test Token List (Test with admin auth)"""
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
token_should: Token = Token.objects.create(
|
|
||||||
identifier="test", expiring=False, user=self.user
|
|
||||||
)
|
|
||||||
token_should_not: Token = Token.objects.create(
|
|
||||||
identifier="test-2", expiring=False, user=get_anonymous_user()
|
|
||||||
)
|
|
||||||
response = self.client.get(reverse(("authentik_api:token-list")))
|
|
||||||
body = loads(response.content)
|
|
||||||
self.assertEqual(len(body["results"]), 2)
|
|
||||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
|
||||||
self.assertEqual(body["results"][1]["identifier"], token_should_not.identifier)
|
|
||||||
|
@ -4,7 +4,7 @@ from django.test import TestCase
|
|||||||
from authentik.core.auth import TokenBackend
|
from authentik.core.auth import TokenBackend
|
||||||
from authentik.core.models import Token, TokenIntents, User
|
from authentik.core.models import Token, TokenIntents, User
|
||||||
from authentik.flows.planner import FlowPlan
|
from authentik.flows.planner import FlowPlan
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
from authentik.flows.views import SESSION_KEY_PLAN
|
||||||
from authentik.lib.tests.utils import get_request
|
from authentik.lib.tests.utils import get_request
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,9 +3,7 @@ from django.urls.base import reverse
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
from authentik.flows.models import Flow, FlowDesignation
|
||||||
from authentik.flows.models import FlowDesignation
|
|
||||||
from authentik.lib.generators import 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
|
||||||
|
|
||||||
@ -14,7 +12,7 @@ class TestUsersAPI(APITestCase):
|
|||||||
"""Test Users API"""
|
"""Test Users API"""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.admin = create_test_admin_user()
|
self.admin = User.objects.get(username="akadmin")
|
||||||
self.user = User.objects.create(username="test-user")
|
self.user = User.objects.create(username="test-user")
|
||||||
|
|
||||||
def test_metrics(self):
|
def test_metrics(self):
|
||||||
@ -41,22 +39,12 @@ class TestUsersAPI(APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
def test_set_password(self):
|
|
||||||
"""Test Direct password set"""
|
|
||||||
self.client.force_login(self.admin)
|
|
||||||
new_pw = generate_key()
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
|
|
||||||
data={"password": new_pw},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 204)
|
|
||||||
self.admin.refresh_from_db()
|
|
||||||
self.assertTrue(self.admin.check_password(new_pw))
|
|
||||||
|
|
||||||
def test_recovery(self):
|
def test_recovery(self):
|
||||||
"""Test user recovery link (no recovery flow set)"""
|
"""Test user recovery link (no recovery flow set)"""
|
||||||
flow = create_test_flow(FlowDesignation.RECOVERY)
|
flow = Flow.objects.create(
|
||||||
tenant: Tenant = create_test_tenant()
|
name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
|
||||||
|
)
|
||||||
|
tenant: Tenant = Tenant.objects.first()
|
||||||
tenant.flow_recovery = flow
|
tenant.flow_recovery = flow
|
||||||
tenant.save()
|
tenant.save()
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
@ -83,8 +71,10 @@ class TestUsersAPI(APITestCase):
|
|||||||
"""Test user recovery link (no email stage)"""
|
"""Test user recovery link (no email stage)"""
|
||||||
self.user.email = "foo@bar.baz"
|
self.user.email = "foo@bar.baz"
|
||||||
self.user.save()
|
self.user.save()
|
||||||
flow = create_test_flow(designation=FlowDesignation.RECOVERY)
|
flow = Flow.objects.create(
|
||||||
tenant: Tenant = create_test_tenant()
|
name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
|
||||||
|
)
|
||||||
|
tenant: Tenant = Tenant.objects.first()
|
||||||
tenant.flow_recovery = flow
|
tenant.flow_recovery = flow
|
||||||
tenant.save()
|
tenant.save()
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
@ -97,8 +87,10 @@ class TestUsersAPI(APITestCase):
|
|||||||
"""Test user recovery link"""
|
"""Test user recovery link"""
|
||||||
self.user.email = "foo@bar.baz"
|
self.user.email = "foo@bar.baz"
|
||||||
self.user.save()
|
self.user.save()
|
||||||
flow = create_test_flow(FlowDesignation.RECOVERY)
|
flow = Flow.objects.create(
|
||||||
tenant: Tenant = create_test_tenant()
|
name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
|
||||||
|
)
|
||||||
|
tenant: Tenant = Tenant.objects.first()
|
||||||
tenant.flow_recovery = flow
|
tenant.flow_recovery = flow
|
||||||
tenant.save()
|
tenant.save()
|
||||||
|
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
"""Test Utils"""
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.utils.text import slugify
|
|
||||||
|
|
||||||
from authentik.core.models import Group, User
|
|
||||||
from authentik.crypto.builder import CertificateBuilder
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
|
||||||
from authentik.flows.models import Flow, FlowDesignation
|
|
||||||
from authentik.lib.generators import generate_id
|
|
||||||
from authentik.tenants.models import Tenant
|
|
||||||
|
|
||||||
|
|
||||||
def create_test_flow(designation: FlowDesignation = FlowDesignation.STAGE_CONFIGURATION) -> Flow:
|
|
||||||
"""Generate a flow that can be used for testing"""
|
|
||||||
uid = generate_id(10)
|
|
||||||
return Flow.objects.create(
|
|
||||||
name=uid,
|
|
||||||
title=uid,
|
|
||||||
slug=slugify(uid),
|
|
||||||
designation=designation,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_test_admin_user(name: Optional[str] = None) -> User:
|
|
||||||
"""Generate a test-admin user"""
|
|
||||||
uid = generate_id(20) if not name else name
|
|
||||||
group = Group.objects.create(name=uid, is_superuser=True)
|
|
||||||
user: User = User.objects.create(
|
|
||||||
username=uid,
|
|
||||||
name=uid,
|
|
||||||
email=f"{uid}@goauthentik.io",
|
|
||||||
)
|
|
||||||
user.set_password(uid)
|
|
||||||
user.save()
|
|
||||||
group.users.add(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def create_test_tenant() -> Tenant:
|
|
||||||
"""Generate a test tenant, removing all other tenants to make sure this one
|
|
||||||
matches."""
|
|
||||||
uid = generate_id(20)
|
|
||||||
Tenant.objects.all().delete()
|
|
||||||
return Tenant.objects.create(domain=uid, default=True)
|
|
||||||
|
|
||||||
|
|
||||||
def create_test_cert() -> CertificateKeyPair:
|
|
||||||
"""Generate a certificate for testing"""
|
|
||||||
CertificateKeyPair.objects.filter(name="goauthentik.io").delete()
|
|
||||||
builder = CertificateBuilder()
|
|
||||||
builder.common_name = "goauthentik.io"
|
|
||||||
builder.build(
|
|
||||||
subject_alt_names=["goauthentik.io"],
|
|
||||||
validity_days=360,
|
|
||||||
)
|
|
||||||
return builder.save()
|
|
@ -29,4 +29,3 @@ class UserSettingSerializer(PassiveSerializer):
|
|||||||
component = CharField()
|
component = CharField()
|
||||||
title = CharField()
|
title = CharField()
|
||||||
configure_url = CharField(required=False)
|
configure_url = CharField(required=False)
|
||||||
icon_url = CharField(required=False)
|
|
||||||
|
@ -5,22 +5,16 @@ from django.views.decorators.csrf import ensure_csrf_cookie
|
|||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
from django.views.generic.base import TemplateView
|
from django.views.generic.base import TemplateView
|
||||||
|
|
||||||
from authentik.core.views import apps, impersonate
|
from authentik.core.views import impersonate
|
||||||
from authentik.core.views.interface import FlowInterfaceView
|
from authentik.core.views.interface import FlowInterfaceView
|
||||||
from authentik.core.views.session import EndSessionView
|
from authentik.core.views.session import EndSessionView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"",
|
"",
|
||||||
login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")),
|
login_required(RedirectView.as_view(pattern_name="authentik_core:if-admin")),
|
||||||
name="root-redirect",
|
name="root-redirect",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
# We have to use this format since everything else uses applications/o or applications/saml
|
|
||||||
"application/launch/<slug:application_slug>/",
|
|
||||||
apps.RedirectToAppLaunch.as_view(),
|
|
||||||
name="application-launch",
|
|
||||||
),
|
|
||||||
# Impersonation
|
# Impersonation
|
||||||
path(
|
path(
|
||||||
"-/impersonation/<int:user_id>/",
|
"-/impersonation/<int:user_id>/",
|
||||||
@ -38,11 +32,6 @@ urlpatterns = [
|
|||||||
ensure_csrf_cookie(TemplateView.as_view(template_name="if/admin.html")),
|
ensure_csrf_cookie(TemplateView.as_view(template_name="if/admin.html")),
|
||||||
name="if-admin",
|
name="if-admin",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"if/user/",
|
|
||||||
ensure_csrf_cookie(TemplateView.as_view(template_name="if/user.html")),
|
|
||||||
name="if-user",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"if/flow/<slug:flow_slug>/",
|
"if/flow/<slug:flow_slug>/",
|
||||||
ensure_csrf_cookie(FlowInterfaceView.as_view()),
|
ensure_csrf_cookie(FlowInterfaceView.as_view()),
|
||||||
|
@ -1,75 +0,0 @@
|
|||||||
"""app views"""
|
|
||||||
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
|
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.views import View
|
|
||||||
|
|
||||||
from authentik.core.models import Application
|
|
||||||
from authentik.flows.challenge import (
|
|
||||||
ChallengeResponse,
|
|
||||||
ChallengeTypes,
|
|
||||||
HttpChallengeResponse,
|
|
||||||
RedirectChallenge,
|
|
||||||
)
|
|
||||||
from authentik.flows.models import in_memory_stage
|
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
|
||||||
from authentik.flows.stage import ChallengeStageView
|
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
|
||||||
from authentik.lib.utils.urls import redirect_with_qs
|
|
||||||
from authentik.stages.consent.stage import (
|
|
||||||
PLAN_CONTEXT_CONSENT_HEADER,
|
|
||||||
PLAN_CONTEXT_CONSENT_PERMISSIONS,
|
|
||||||
)
|
|
||||||
from authentik.tenants.models import Tenant
|
|
||||||
|
|
||||||
|
|
||||||
class RedirectToAppLaunch(View):
|
|
||||||
"""Application launch view, redirect to the launch URL"""
|
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
|
||||||
app = get_object_or_404(Application, slug=application_slug)
|
|
||||||
# Check here if the application has any launch URL set, if not 404
|
|
||||||
launch = app.get_launch_url()
|
|
||||||
if not launch:
|
|
||||||
raise Http404
|
|
||||||
# Check if we're authenticated already, saves us the flow run
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
return HttpResponseRedirect(app.get_launch_url(request.user))
|
|
||||||
# otherwise, do a custom flow plan that includes the application that's
|
|
||||||
# being accessed, to improve usability
|
|
||||||
tenant: Tenant = request.tenant
|
|
||||||
flow = tenant.flow_authentication
|
|
||||||
planner = FlowPlanner(flow)
|
|
||||||
planner.allow_empty_flows = True
|
|
||||||
plan = planner.plan(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
PLAN_CONTEXT_APPLICATION: app,
|
|
||||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
|
||||||
% {"application": app.name},
|
|
||||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
plan.insert_stage(in_memory_stage(RedirectToAppStage))
|
|
||||||
request.session[SESSION_KEY_PLAN] = plan
|
|
||||||
return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug)
|
|
||||||
|
|
||||||
|
|
||||||
class RedirectToAppStage(ChallengeStageView):
|
|
||||||
"""Final stage to be inserted after the user logs in"""
|
|
||||||
|
|
||||||
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
|
|
||||||
app = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
|
||||||
launch = app.get_launch_url(self.get_pending_user())
|
|
||||||
# sanity check to ensure launch is still set
|
|
||||||
if not launch:
|
|
||||||
raise Http404
|
|
||||||
return RedirectChallenge(
|
|
||||||
instance={
|
|
||||||
"type": ChallengeTypes.REDIRECT.value,
|
|
||||||
"to": launch,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
|
||||||
return HttpChallengeResponse(self.get_challenge())
|
|
@ -28,7 +28,7 @@ class ImpersonateInitView(View):
|
|||||||
|
|
||||||
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
||||||
|
|
||||||
return redirect("authentik_core:if-user")
|
return redirect("authentik_core:if-admin")
|
||||||
|
|
||||||
|
|
||||||
class ImpersonateEndView(View):
|
class ImpersonateEndView(View):
|
||||||
@ -41,7 +41,7 @@ class ImpersonateEndView(View):
|
|||||||
or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session
|
or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session
|
||||||
):
|
):
|
||||||
LOGGER.debug("Can't end impersonation", user=request.user)
|
LOGGER.debug("Can't end impersonation", user=request.user)
|
||||||
return redirect("authentik_core:if-user")
|
return redirect("authentik_core:if-admin")
|
||||||
|
|
||||||
original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
||||||
|
|
||||||
|
@ -14,5 +14,4 @@ class FlowInterfaceView(TemplateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
||||||
kwargs["inspector"] = "inspector" in self.request.GET
|
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""Crypto API Views"""
|
"""Crypto API Views"""
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||||
from cryptography.x509 import load_pem_x509_certificate
|
from cryptography.x509 import load_pem_x509_certificate
|
||||||
@ -17,18 +15,14 @@ from rest_framework.request import Request
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ModelSerializer, ValidationError
|
from rest_framework.serializers import ModelSerializer, ValidationError
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
from authentik.crypto.managed import MANAGED_KEY
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class CertificateKeyPairSerializer(ModelSerializer):
|
class CertificateKeyPairSerializer(ModelSerializer):
|
||||||
"""CertificateKeyPair Serializer"""
|
"""CertificateKeyPair Serializer"""
|
||||||
@ -36,7 +30,6 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
|||||||
cert_expiry = DateTimeField(source="certificate.not_valid_after", read_only=True)
|
cert_expiry = DateTimeField(source="certificate.not_valid_after", read_only=True)
|
||||||
cert_subject = SerializerMethodField()
|
cert_subject = SerializerMethodField()
|
||||||
private_key_available = SerializerMethodField()
|
private_key_available = SerializerMethodField()
|
||||||
private_key_type = SerializerMethodField()
|
|
||||||
|
|
||||||
certificate_download_url = SerializerMethodField()
|
certificate_download_url = SerializerMethodField()
|
||||||
private_key_download_url = SerializerMethodField()
|
private_key_download_url = SerializerMethodField()
|
||||||
@ -49,13 +42,6 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
|||||||
"""Show if this keypair has a private key configured or not"""
|
"""Show if this keypair has a private key configured or not"""
|
||||||
return instance.key_data != "" and instance.key_data is not None
|
return instance.key_data != "" and instance.key_data is not None
|
||||||
|
|
||||||
def get_private_key_type(self, instance: CertificateKeyPair) -> Optional[str]:
|
|
||||||
"""Get the private key's type, if set"""
|
|
||||||
key = instance.private_key
|
|
||||||
if key:
|
|
||||||
return key.__class__.__name__.replace("_", "").lower().replace("privatekey", "")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
|
def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
|
||||||
"""Get URL to download certificate"""
|
"""Get URL to download certificate"""
|
||||||
return (
|
return (
|
||||||
@ -79,30 +65,22 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
|||||||
def validate_certificate_data(self, value: str) -> str:
|
def validate_certificate_data(self, value: str) -> str:
|
||||||
"""Verify that input is a valid PEM x509 Certificate"""
|
"""Verify that input is a valid PEM x509 Certificate"""
|
||||||
try:
|
try:
|
||||||
# Cast to string to fully load and parse certificate
|
load_pem_x509_certificate(value.encode("utf-8"), default_backend())
|
||||||
# Prevents issues like https://github.com/goauthentik/authentik/issues/2082
|
except ValueError:
|
||||||
str(load_pem_x509_certificate(value.encode("utf-8"), default_backend()))
|
|
||||||
except ValueError as exc:
|
|
||||||
LOGGER.warning("Failed to load certificate", exc=exc)
|
|
||||||
raise ValidationError("Unable to load certificate.")
|
raise ValidationError("Unable to load certificate.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_key_data(self, value: str) -> str:
|
def validate_key_data(self, value: str) -> str:
|
||||||
"""Verify that input is a valid PEM Key"""
|
"""Verify that input is a valid PEM RSA Key"""
|
||||||
# Since this field is optional, data can be empty.
|
# Since this field is optional, data can be empty.
|
||||||
if value != "":
|
if value != "":
|
||||||
try:
|
try:
|
||||||
# Cast to string to fully load and parse certificate
|
load_pem_private_key(
|
||||||
# Prevents issues like https://github.com/goauthentik/authentik/issues/2082
|
str.encode("\n".join([x.strip() for x in value.split("\n")])),
|
||||||
str(
|
password=None,
|
||||||
load_pem_private_key(
|
backend=default_backend(),
|
||||||
str.encode("\n".join([x.strip() for x in value.split("\n")])),
|
|
||||||
password=None,
|
|
||||||
backend=default_backend(),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError):
|
||||||
LOGGER.warning("Failed to load private key", exc=exc)
|
|
||||||
raise ValidationError("Unable to load private key (possibly encrypted?).")
|
raise ValidationError("Unable to load private key (possibly encrypted?).")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@ -119,10 +97,8 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
|||||||
"cert_expiry",
|
"cert_expiry",
|
||||||
"cert_subject",
|
"cert_subject",
|
||||||
"private_key_available",
|
"private_key_available",
|
||||||
"private_key_type",
|
|
||||||
"certificate_download_url",
|
"certificate_download_url",
|
||||||
"private_key_download_url",
|
"private_key_download_url",
|
||||||
"managed",
|
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"key_data": {"write_only": True},
|
"key_data": {"write_only": True},
|
||||||
@ -158,17 +134,15 @@ class CertificateKeyPairFilter(FilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CertificateKeyPair
|
model = CertificateKeyPair
|
||||||
fields = ["name", "managed"]
|
fields = ["name"]
|
||||||
|
|
||||||
|
|
||||||
class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||||
"""CertificateKeyPair Viewset"""
|
"""CertificateKeyPair Viewset"""
|
||||||
|
|
||||||
queryset = CertificateKeyPair.objects.exclude(managed=MANAGED_KEY)
|
queryset = CertificateKeyPair.objects.all()
|
||||||
serializer_class = CertificateKeyPairSerializer
|
serializer_class = CertificateKeyPairSerializer
|
||||||
filterset_class = CertificateKeyPairFilter
|
filterset_class = CertificateKeyPairFilter
|
||||||
ordering = ["name"]
|
|
||||||
search_fields = ["name"]
|
|
||||||
|
|
||||||
@permission_required(None, ["authentik_crypto.add_certificatekeypair"])
|
@permission_required(None, ["authentik_crypto.add_certificatekeypair"])
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@ -214,7 +188,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
secret=certificate,
|
secret=certificate,
|
||||||
type="certificate",
|
type="certificate",
|
||||||
).from_http(request)
|
).from_http(request)
|
||||||
if "download" in request.query_params:
|
if "download" in request._request.GET:
|
||||||
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
|
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
|
||||||
response = HttpResponse(
|
response = HttpResponse(
|
||||||
certificate.certificate_data, content_type="application/x-pem-file"
|
certificate.certificate_data, content_type="application/x-pem-file"
|
||||||
@ -245,7 +219,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
secret=certificate,
|
secret=certificate,
|
||||||
type="private_key",
|
type="private_key",
|
||||||
).from_http(request)
|
).from_http(request)
|
||||||
if "download" in request.query_params:
|
if "download" in request._request.GET:
|
||||||
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
|
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
|
||||||
response = HttpResponse(certificate.key_data, content_type="application/x-pem-file")
|
response = HttpResponse(certificate.key_data, content_type="application/x-pem-file")
|
||||||
response[
|
response[
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""authentik crypto app config"""
|
"""authentik crypto app config"""
|
||||||
from importlib import import_module
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
@ -10,7 +8,3 @@ class AuthentikCryptoConfig(AppConfig):
|
|||||||
name = "authentik.crypto"
|
name = "authentik.crypto"
|
||||||
label = "authentik_crypto"
|
label = "authentik_crypto"
|
||||||
verbose_name = "authentik Crypto"
|
verbose_name = "authentik Crypto"
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
import_module("authentik.crypto.managed")
|
|
||||||
import_module("authentik.crypto.tasks")
|
|
||||||
|
@ -24,17 +24,16 @@ class CertificateBuilder:
|
|||||||
self.__builder = None
|
self.__builder = None
|
||||||
self.__certificate = None
|
self.__certificate = None
|
||||||
self.common_name = "authentik Self-signed Certificate"
|
self.common_name = "authentik Self-signed Certificate"
|
||||||
self.cert = CertificateKeyPair()
|
|
||||||
|
|
||||||
def save(self) -> Optional[CertificateKeyPair]:
|
def save(self) -> Optional[CertificateKeyPair]:
|
||||||
"""Save generated certificate as model"""
|
"""Save generated certificate as model"""
|
||||||
if not self.__certificate:
|
if not self.__certificate:
|
||||||
raise ValueError("Certificated hasn't been built yet")
|
raise ValueError("Certificated hasn't been built yet")
|
||||||
self.cert.name = self.common_name
|
return CertificateKeyPair.objects.create(
|
||||||
self.cert.certificate_data = self.certificate
|
name=self.common_name,
|
||||||
self.cert.key_data = self.private_key
|
certificate_data=self.certificate,
|
||||||
self.cert.save()
|
key_data=self.private_key,
|
||||||
return self.cert
|
)
|
||||||
|
|
||||||
def build(
|
def build(
|
||||||
self,
|
self,
|
||||||
@ -44,7 +43,7 @@ class CertificateBuilder:
|
|||||||
"""Build self-signed certificate"""
|
"""Build self-signed certificate"""
|
||||||
one_day = datetime.timedelta(1, 0, 0)
|
one_day = datetime.timedelta(1, 0, 0)
|
||||||
self.__private_key = rsa.generate_private_key(
|
self.__private_key = rsa.generate_private_key(
|
||||||
public_exponent=65537, key_size=4096, backend=default_backend()
|
public_exponent=65537, key_size=2048, backend=default_backend()
|
||||||
)
|
)
|
||||||
self.__public_key = self.__private_key.public_key()
|
self.__public_key = self.__private_key.public_key()
|
||||||
alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []]
|
alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []]
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
"""Crypto managed objects"""
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from authentik.crypto.builder import CertificateBuilder
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
|
||||||
from authentik.managed.manager import ObjectManager
|
|
||||||
|
|
||||||
MANAGED_KEY = "goauthentik.io/crypto/jwt-managed"
|
|
||||||
|
|
||||||
|
|
||||||
class CryptoManager(ObjectManager):
|
|
||||||
"""Crypto managed objects"""
|
|
||||||
|
|
||||||
def _create(self, cert: Optional[CertificateKeyPair] = None):
|
|
||||||
builder = CertificateBuilder()
|
|
||||||
builder.common_name = "goauthentik.io"
|
|
||||||
builder.build(
|
|
||||||
subject_alt_names=["goauthentik.io"],
|
|
||||||
validity_days=360,
|
|
||||||
)
|
|
||||||
if not cert:
|
|
||||||
cert = CertificateKeyPair()
|
|
||||||
cert.certificate_data = builder.certificate
|
|
||||||
cert.key_data = builder.private_key
|
|
||||||
cert.name = "authentik Internal JWT Certificate"
|
|
||||||
cert.managed = MANAGED_KEY
|
|
||||||
cert.save()
|
|
||||||
|
|
||||||
def reconcile(self):
|
|
||||||
certs = CertificateKeyPair.objects.filter(managed=MANAGED_KEY)
|
|
||||||
if not certs.exists():
|
|
||||||
self._create()
|
|
||||||
return []
|
|
||||||
cert: CertificateKeyPair = certs.first()
|
|
||||||
now = datetime.now()
|
|
||||||
if now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after:
|
|
||||||
self._create(cert)
|
|
||||||
return []
|
|
||||||
return []
|
|
@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 3.2.8 on 2021-10-09 17:05
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_crypto", "0002_create_self_signed_kp"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="certificatekeypair",
|
|
||||||
name="managed",
|
|
||||||
field=models.TextField(
|
|
||||||
default=None,
|
|
||||||
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.",
|
|
||||||
null=True,
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Managed by authentik",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -6,25 +6,16 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
from cryptography.hazmat.primitives import hashes
|
from cryptography.hazmat.primitives import hashes
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
||||||
EllipticCurvePrivateKey,
|
|
||||||
EllipticCurvePublicKey,
|
|
||||||
)
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||||
from cryptography.x509 import Certificate, load_pem_x509_certificate
|
from cryptography.x509 import Certificate, load_pem_x509_certificate
|
||||||
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 structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.lib.models import CreatedUpdatedModel
|
from authentik.lib.models import CreatedUpdatedModel
|
||||||
from authentik.managed.models import ManagedModel
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
|
class CertificateKeyPair(CreatedUpdatedModel):
|
||||||
"""CertificateKeyPair that can be used for signing or encrypting if `key_data`
|
"""CertificateKeyPair that can be used for signing or encrypting if `key_data`
|
||||||
is set, otherwise it can be used to verify remote data."""
|
is set, otherwise it can be used to verify remote data."""
|
||||||
|
|
||||||
@ -41,8 +32,8 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
_cert: Optional[Certificate] = None
|
_cert: Optional[Certificate] = None
|
||||||
_private_key: Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey] = None
|
_private_key: Optional[RSAPrivateKey] = None
|
||||||
_public_key: Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey] = None
|
_public_key: Optional[RSAPublicKey] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def certificate(self) -> Certificate:
|
def certificate(self) -> Certificate:
|
||||||
@ -54,26 +45,23 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
|
|||||||
return self._cert
|
return self._cert
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def public_key(self) -> Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey]:
|
def public_key(self) -> Optional[RSAPublicKey]:
|
||||||
"""Get public key of the private key"""
|
"""Get public key of the private key"""
|
||||||
if not self._public_key:
|
if not self._public_key:
|
||||||
self._public_key = self.private_key.public_key()
|
self._public_key = self.private_key.public_key()
|
||||||
return self._public_key
|
return self._public_key
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def private_key(
|
def private_key(self) -> Optional[RSAPrivateKey]:
|
||||||
self,
|
|
||||||
) -> Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey]:
|
|
||||||
"""Get python cryptography PrivateKey instance"""
|
"""Get python cryptography PrivateKey instance"""
|
||||||
if not self._private_key and self.key_data != "":
|
if not self._private_key and self._private_key != "":
|
||||||
try:
|
try:
|
||||||
self._private_key = load_pem_private_key(
|
self._private_key = load_pem_private_key(
|
||||||
str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])),
|
str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])),
|
||||||
password=None,
|
password=None,
|
||||||
backend=default_backend(),
|
backend=default_backend(),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError:
|
||||||
LOGGER.warning(exc)
|
|
||||||
return None
|
return None
|
||||||
return self._private_key
|
return self._private_key
|
||||||
|
|
||||||
@ -90,7 +78,9 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
|
|||||||
@property
|
@property
|
||||||
def kid(self):
|
def kid(self):
|
||||||
"""Get Key ID used for JWKS"""
|
"""Get Key ID used for JWKS"""
|
||||||
return md5(self.key_data.encode("utf-8")).hexdigest() if self.key_data else "" # nosec
|
return "{0}".format(
|
||||||
|
md5(self.key_data.encode("utf-8")).hexdigest() if self.key_data else "" # nosec
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Certificate-Key Pair {self.name}"
|
return f"Certificate-Key Pair {self.name}"
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
"""Crypto task Settings"""
|
|
||||||
from celery.schedules import crontab
|
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
|
||||||
"crypto_certificate_discovery": {
|
|
||||||
"task": "authentik.crypto.tasks.certificate_discovery",
|
|
||||||
"schedule": crontab(minute="*/5"),
|
|
||||||
"options": {"queue": "authentik_scheduled"},
|
|
||||||
},
|
|
||||||
}
|
|
@ -1,95 +0,0 @@
|
|||||||
"""Crypto tasks"""
|
|
||||||
from glob import glob
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
||||||
from cryptography.x509.base import load_pem_x509_certificate
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
|
||||||
from authentik.events.monitored_tasks import (
|
|
||||||
MonitoredTask,
|
|
||||||
TaskResult,
|
|
||||||
TaskResultStatus,
|
|
||||||
prefill_task,
|
|
||||||
)
|
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
from authentik.root.celery import CELERY_APP
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
MANAGED_DISCOVERED = "goauthentik.io/crypto/discovered/%s"
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_private_key_valid(body: str):
|
|
||||||
"""Attempt loading of a PEM Private key without password"""
|
|
||||||
load_pem_private_key(
|
|
||||||
str.encode("\n".join([x.strip() for x in body.split("\n")])),
|
|
||||||
password=None,
|
|
||||||
backend=default_backend(),
|
|
||||||
)
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_certificate_valid(body: str):
|
|
||||||
"""Attempt loading of a PEM-encoded certificate"""
|
|
||||||
load_pem_x509_certificate(body.encode("utf-8"), default_backend())
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
|
||||||
@prefill_task
|
|
||||||
def certificate_discovery(self: MonitoredTask):
|
|
||||||
"""Discover, import and update certificates from the filesystem"""
|
|
||||||
certs = {}
|
|
||||||
private_keys = {}
|
|
||||||
discovered = 0
|
|
||||||
for file in glob(CONFIG.y("cert_discovery_dir") + "/**", recursive=True):
|
|
||||||
path = Path(file)
|
|
||||||
if not path.exists():
|
|
||||||
continue
|
|
||||||
if path.is_dir():
|
|
||||||
continue
|
|
||||||
# For certbot setups, we want to ignore archive.
|
|
||||||
if "archive" in file:
|
|
||||||
continue
|
|
||||||
# Support certbot's directory structure
|
|
||||||
if path.name in ["fullchain.pem", "privkey.pem"]:
|
|
||||||
cert_name = path.parent.name
|
|
||||||
else:
|
|
||||||
cert_name = path.name.replace(path.suffix, "")
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as _file:
|
|
||||||
body = _file.read()
|
|
||||||
if "PRIVATE KEY" in body:
|
|
||||||
private_keys[cert_name] = ensure_private_key_valid(body)
|
|
||||||
else:
|
|
||||||
certs[cert_name] = ensure_certificate_valid(body)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
LOGGER.warning("Failed to open file or invalid format", exc=exc, file=path)
|
|
||||||
discovered += 1
|
|
||||||
for name, cert_data in certs.items():
|
|
||||||
cert = CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % name).first()
|
|
||||||
if not cert:
|
|
||||||
cert = CertificateKeyPair(
|
|
||||||
name=name,
|
|
||||||
managed=MANAGED_DISCOVERED % name,
|
|
||||||
)
|
|
||||||
dirty = False
|
|
||||||
if cert.certificate_data != cert_data:
|
|
||||||
cert.certificate_data = cert_data
|
|
||||||
dirty = True
|
|
||||||
if name in private_keys:
|
|
||||||
if cert.key_data != private_keys[name]:
|
|
||||||
cert.key_data = private_keys[name]
|
|
||||||
dirty = True
|
|
||||||
if dirty:
|
|
||||||
cert.save()
|
|
||||||
self.set_status(
|
|
||||||
TaskResult(
|
|
||||||
TaskResultStatus.SUCCESSFUL,
|
|
||||||
messages=[_("Successfully imported %(count)d files." % {"count": discovered})],
|
|
||||||
)
|
|
||||||
)
|
|
@ -1,37 +1,25 @@
|
|||||||
"""Crypto tests"""
|
"""Crypto tests"""
|
||||||
import datetime
|
import datetime
|
||||||
from os import makedirs
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from authentik.core.api.used_by import DeleteAction
|
from authentik.core.api.used_by import DeleteAction
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
from authentik.core.models import User
|
||||||
from authentik.crypto.api import CertificateKeyPairSerializer
|
from authentik.crypto.api import CertificateKeyPairSerializer
|
||||||
from authentik.crypto.builder import CertificateBuilder
|
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.flows.models import Flow
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
from authentik.lib.generators import generate_key
|
from authentik.lib.generators import generate_key
|
||||||
from authentik.providers.oauth2.models import OAuth2Provider
|
from authentik.providers.oauth2.models import OAuth2Provider
|
||||||
|
|
||||||
|
|
||||||
class TestCrypto(APITestCase):
|
class TestCrypto(TestCase):
|
||||||
"""Test Crypto validation"""
|
"""Test Crypto validation"""
|
||||||
|
|
||||||
def test_model_private(self):
|
|
||||||
"""Test model private key"""
|
|
||||||
cert = CertificateKeyPair.objects.create(
|
|
||||||
name="test",
|
|
||||||
certificate_data="foo",
|
|
||||||
key_data="foo",
|
|
||||||
)
|
|
||||||
self.assertIsNone(cert.private_key)
|
|
||||||
|
|
||||||
def test_serializer(self):
|
def test_serializer(self):
|
||||||
"""Test API Validation"""
|
"""Test API Validation"""
|
||||||
keypair = create_test_cert()
|
keypair = CertificateKeyPair.objects.first()
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
CertificateKeyPairSerializer(
|
CertificateKeyPairSerializer(
|
||||||
data={
|
data={
|
||||||
@ -66,38 +54,10 @@ class TestCrypto(APITestCase):
|
|||||||
self.assertEqual(instance.name, "test-cert")
|
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):
|
|
||||||
"""Test Builder (via API)"""
|
|
||||||
self.client.force_login(create_test_admin_user())
|
|
||||||
self.client.post(
|
|
||||||
reverse("authentik_api:certificatekeypair-generate"),
|
|
||||||
data={"common_name": "foo", "subject_alt_name": "bar,baz", "validity_days": 3},
|
|
||||||
)
|
|
||||||
self.assertTrue(CertificateKeyPair.objects.filter(name="foo").exists())
|
|
||||||
|
|
||||||
def test_builder_api_invalid(self):
|
|
||||||
"""Test Builder (via API) (invalid)"""
|
|
||||||
self.client.force_login(create_test_admin_user())
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("authentik_api:certificatekeypair-generate"),
|
|
||||||
data={},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
|
|
||||||
def test_list(self):
|
|
||||||
"""Test API List"""
|
|
||||||
self.client.force_login(create_test_admin_user())
|
|
||||||
response = self.client.get(
|
|
||||||
reverse(
|
|
||||||
"authentik_api:certificatekeypair-list",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(200, response.status_code)
|
|
||||||
|
|
||||||
def test_certificate_download(self):
|
def test_certificate_download(self):
|
||||||
"""Test certificate export (download)"""
|
"""Test certificate export (download)"""
|
||||||
self.client.force_login(create_test_admin_user())
|
self.client.force_login(User.objects.get(username="akadmin"))
|
||||||
keypair = create_test_cert()
|
keypair = CertificateKeyPair.objects.first()
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse(
|
reverse(
|
||||||
"authentik_api:certificatekeypair-view-certificate",
|
"authentik_api:certificatekeypair-view-certificate",
|
||||||
@ -117,8 +77,8 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_private_key_download(self):
|
def test_private_key_download(self):
|
||||||
"""Test private_key export (download)"""
|
"""Test private_key export (download)"""
|
||||||
self.client.force_login(create_test_admin_user())
|
self.client.force_login(User.objects.get(username="akadmin"))
|
||||||
keypair = create_test_cert()
|
keypair = CertificateKeyPair.objects.first()
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse(
|
reverse(
|
||||||
"authentik_api:certificatekeypair-view-private-key",
|
"authentik_api:certificatekeypair-view-private-key",
|
||||||
@ -138,15 +98,15 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_used_by(self):
|
def test_used_by(self):
|
||||||
"""Test used_by endpoint"""
|
"""Test used_by endpoint"""
|
||||||
self.client.force_login(create_test_admin_user())
|
self.client.force_login(User.objects.get(username="akadmin"))
|
||||||
keypair = create_test_cert()
|
keypair = CertificateKeyPair.objects.first()
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="test",
|
name="test",
|
||||||
client_id="test",
|
client_id="test",
|
||||||
client_secret=generate_key(),
|
client_secret=generate_key(),
|
||||||
authorization_flow=create_test_flow(),
|
authorization_flow=Flow.objects.first(),
|
||||||
redirect_uris="http://localhost",
|
redirect_uris="http://localhost",
|
||||||
signing_key=keypair,
|
rsa_key=CertificateKeyPair.objects.first(),
|
||||||
)
|
)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse(
|
reverse(
|
||||||
@ -167,36 +127,3 @@ class TestCrypto(APITestCase):
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_discovery(self):
|
|
||||||
"""Test certificate discovery"""
|
|
||||||
builder = CertificateBuilder()
|
|
||||||
builder.common_name = "test-cert"
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
builder.save()
|
|
||||||
builder.build(
|
|
||||||
subject_alt_names=[],
|
|
||||||
validity_days=3,
|
|
||||||
)
|
|
||||||
with TemporaryDirectory() as temp_dir:
|
|
||||||
with open(f"{temp_dir}/foo.pem", "w+", encoding="utf-8") as _cert:
|
|
||||||
_cert.write(builder.certificate)
|
|
||||||
with open(f"{temp_dir}/foo.key", "w+", encoding="utf-8") as _key:
|
|
||||||
_key.write(builder.private_key)
|
|
||||||
makedirs(f"{temp_dir}/foo.bar", exist_ok=True)
|
|
||||||
with open(f"{temp_dir}/foo.bar/fullchain.pem", "w+", encoding="utf-8") as _cert:
|
|
||||||
_cert.write(builder.certificate)
|
|
||||||
with open(f"{temp_dir}/foo.bar/privkey.pem", "w+", encoding="utf-8") as _key:
|
|
||||||
_key.write(builder.private_key)
|
|
||||||
with CONFIG.patch("cert_discovery_dir", temp_dir):
|
|
||||||
# pyright: reportGeneralTypeIssues=false
|
|
||||||
certificate_discovery() # pylint: disable=no-value-for-parameter
|
|
||||||
keypair: CertificateKeyPair = CertificateKeyPair.objects.filter(
|
|
||||||
managed=MANAGED_DISCOVERED % "foo"
|
|
||||||
).first()
|
|
||||||
self.assertIsNotNone(keypair)
|
|
||||||
self.assertIsNotNone(keypair.certificate)
|
|
||||||
self.assertIsNotNone(keypair.private_key)
|
|
||||||
self.assertTrue(
|
|
||||||
CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo.bar").exists()
|
|
||||||
)
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""Events API Views"""
|
"""Events API Views"""
|
||||||
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
|
||||||
@ -14,7 +12,6 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.serializers import ModelSerializer
|
from rest_framework.serializers import ModelSerializer
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.admin.api.metrics import CoordinateSerializer
|
|
||||||
from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
|
from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
|
||||||
@ -113,20 +110,13 @@ class EventViewSet(ModelViewSet):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
methods=["GET"],
|
methods=["GET"],
|
||||||
responses={200: EventTopPerUserSerializer(many=True)},
|
responses={200: EventTopPerUserSerializer(many=True)},
|
||||||
filters=[],
|
|
||||||
parameters=[
|
parameters=[
|
||||||
OpenApiParameter(
|
|
||||||
"action",
|
|
||||||
type=OpenApiTypes.STR,
|
|
||||||
location=OpenApiParameter.QUERY,
|
|
||||||
required=False,
|
|
||||||
),
|
|
||||||
OpenApiParameter(
|
OpenApiParameter(
|
||||||
"top_n",
|
"top_n",
|
||||||
type=OpenApiTypes.INT,
|
type=OpenApiTypes.INT,
|
||||||
location=OpenApiParameter.QUERY,
|
location=OpenApiParameter.QUERY,
|
||||||
required=False,
|
required=False,
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@action(detail=False, methods=["GET"], pagination_class=None)
|
@action(detail=False, methods=["GET"], pagination_class=None)
|
||||||
@ -147,40 +137,6 @@ class EventViewSet(ModelViewSet):
|
|||||||
.order_by("-counted_events")[:top_n]
|
.order_by("-counted_events")[:top_n]
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
methods=["GET"],
|
|
||||||
responses={200: CoordinateSerializer(many=True)},
|
|
||||||
filters=[],
|
|
||||||
parameters=[
|
|
||||||
OpenApiParameter(
|
|
||||||
"action",
|
|
||||||
type=OpenApiTypes.STR,
|
|
||||||
location=OpenApiParameter.QUERY,
|
|
||||||
required=False,
|
|
||||||
),
|
|
||||||
OpenApiParameter(
|
|
||||||
"query",
|
|
||||||
type=OpenApiTypes.STR,
|
|
||||||
location=OpenApiParameter.QUERY,
|
|
||||||
required=False,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@action(detail=False, methods=["GET"], pagination_class=None)
|
|
||||||
def per_month(self, request: Request):
|
|
||||||
"""Get the count of events per month"""
|
|
||||||
filtered_action = request.query_params.get("action", EventAction.LOGIN)
|
|
||||||
try:
|
|
||||||
query = loads(request.query_params.get("query", "{}"))
|
|
||||||
except ValueError:
|
|
||||||
return Response(status=400)
|
|
||||||
return Response(
|
|
||||||
get_objects_for_user(request.user, "authentik_events.view_event")
|
|
||||||
.filter(action=filtered_action)
|
|
||||||
.filter(**query)
|
|
||||||
.get_events_per_day()
|
|
||||||
)
|
|
||||||
|
|
||||||
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
||||||
@action(detail=False, pagination_class=None, filter_backends=[])
|
@action(detail=False, pagination_class=None, filter_backends=[])
|
||||||
def actions(self, request: Request) -> Response:
|
def actions(self, request: Request) -> Response:
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user