Compare commits
193 Commits
version-20
...
version-20
Author | SHA1 | Date | |
---|---|---|---|
9b9c0fe663 | |||
5a58f6ee64 | |||
da83c3af53 | |||
e84b17d550 | |||
b4fb0190a3 | |||
bb52b95e5b | |||
a2b5d667af | |||
2df9c0479d | |||
5c673dc7bb | |||
da2dd7daf4 | |||
f2a80030d7 | |||
918183f472 | |||
9da439623b | |||
957bb1c5ef | |||
677d46d7fd | |||
5af7baf36c | |||
8b2ca822f5 | |||
2303a97bb9 | |||
8be04cc013 | |||
9b6e47e6b8 | |||
677621989a | |||
0d5125db76 | |||
ed88f6594c | |||
b1816f2101 | |||
fe60c26e11 | |||
cca33a74b6 | |||
f977bf61eb | |||
f8f8a9bbb9 | |||
7a44d5768a | |||
d9e4219d70 | |||
6db5df1b31 | |||
e64ca4ab04 | |||
0e59ed62f5 | |||
dfe3394d4e | |||
9d4fb8048c | |||
a7a517733e | |||
e2f0a76309 | |||
07267ac425 | |||
8fb7620004 | |||
2ef85c4447 | |||
c3174ac044 | |||
952b48541c | |||
a97ffce5f9 | |||
5d514bd8c4 | |||
128234324d | |||
2d1bc2efcc | |||
2a1af96838 | |||
a6674440e6 | |||
5861d41ad3 | |||
fcd9c58a73 | |||
4bf2878cf7 | |||
79d508a020 | |||
03916b0b25 | |||
263964865c | |||
21f92b4a65 | |||
e38d03b304 | |||
f2b540ed8a | |||
79ad356d90 | |||
e70490481d | |||
66ab9504e9 | |||
009173fe23 | |||
75a5335f0f | |||
7a9452c66a | |||
82a999f95d | |||
0c2e9234bf | |||
964a3276a1 | |||
5185b027dc | |||
d690296120 | |||
9252a1f9d3 | |||
fc6742a17e | |||
31546da796 | |||
4a6c46a5c9 | |||
20262f3f4b | |||
dea61ef35e | |||
edda644e28 | |||
ee13ec1dca | |||
39bea1d5d0 | |||
453dcd790f | |||
bb70e6c81d | |||
4ff9db9d7e | |||
8b2e70d15d | |||
8e2f929933 | |||
ae2d86096b | |||
849c347e8c | |||
c974298836 | |||
b46eb7198b | |||
37db6764ab | |||
633296503d | |||
508cec2fd5 | |||
7a93614e4b | |||
4f319eaa4f | |||
86a8d00b3f | |||
5fe8c1f3d7 | |||
be91d893fb | |||
1fc6aa5a02 | |||
2256baced5 | |||
f2af904aeb | |||
030f612c38 | |||
d84ff2bbca | |||
4be238018b | |||
71c6313c46 | |||
f7daa7723d | |||
1ff35eef4c | |||
743bb3e98f | |||
83c4d5393c | |||
99008252f8 | |||
4cf00ed5cf | |||
8689444954 | |||
4210f692ff | |||
85a3578092 | |||
6b05d44d1f | |||
49b221ed68 | |||
67b43c223c | |||
5f9dc4395a | |||
bb8af2f19b | |||
996bd05ba6 | |||
ac03f5a97d | |||
a1a64e25ee | |||
53851efacb | |||
afea262e14 | |||
53f92f01da | |||
a267686098 | |||
9ee06b7d1f | |||
f53343141e | |||
62250f4ec6 | |||
485329130b | |||
6891c239e2 | |||
993c6472db | |||
123b0b2f05 | |||
487b1e4f34 | |||
b308cfa8d7 | |||
839884c65c | |||
dc93f5d4c9 | |||
735af9aaad | |||
9c52ee585f | |||
4c5f01020e | |||
fc315eb8da | |||
b90d8b14d6 | |||
1af49c930c | |||
624ae67b50 | |||
cd2fb49f9b | |||
3da531ede3 | |||
e3e4b2f818 | |||
98391da0d0 | |||
1555aed02f | |||
7a01529511 | |||
bc3e6b3962 | |||
7cbd5174f0 | |||
788cd401f6 | |||
bec8c8fe0a | |||
3184a64482 | |||
c7a83e6182 | |||
933919c647 | |||
7d3841e85f | |||
21e54d803f | |||
883af97148 | |||
3184019996 | |||
c0edaaf821 | |||
74ff9d04dd | |||
969902f503 | |||
04372e21dd | |||
0c53650216 | |||
8e028c2feb | |||
d75a864f0e | |||
81f3b133f6 | |||
b887916f5b | |||
2a354aa64f | |||
d9724e6885 | |||
d092e8e4bc | |||
e5b8975459 | |||
4f4784f4d8 | |||
51194cbf42 | |||
4d5a619cc0 | |||
2314340823 | |||
7c6b2c843b | |||
0c2b32da31 | |||
9ad4c736f1 | |||
0c0b9ca84a | |||
4154b62565 | |||
5a07d4ec66 | |||
64b758c8fa | |||
a0e29d42a6 | |||
0bbea79c64 | |||
467ad29656 | |||
d2fc1226f8 | |||
5c50a18b6f | |||
75505a2077 | |||
6d7525b5a1 | |||
4ca7ba427a | |||
740fafa86d | |||
4b80f52e11 | |||
7ae2bdc35f | |||
34473903dd |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2022.3.3
|
||||
current_version = 2022.4.1
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
||||
|
49
.github/actions/docker-setup/action.yml
vendored
Normal file
49
.github/actions/docker-setup/action.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
name: 'Prepare docker environment variables'
|
||||
description: 'Prepare docker environment variables'
|
||||
|
||||
outputs:
|
||||
shouldBuild:
|
||||
description: "Whether to build image or not"
|
||||
value: ${{ steps.ev.outputs.shouldBuild }}
|
||||
branchName:
|
||||
description: "Branch name"
|
||||
value: ${{ steps.ev.outputs.branchName }}
|
||||
branchNameContainer:
|
||||
description: "Branch name (for containers)"
|
||||
value: ${{ steps.ev.outputs.branchNameContainer }}
|
||||
timestamp:
|
||||
description: "Timestamp"
|
||||
value: ${{ steps.ev.outputs.timestamp }}
|
||||
sha:
|
||||
description: "sha"
|
||||
value: ${{ steps.ev.outputs.sha }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Generate config
|
||||
id: ev
|
||||
shell: python
|
||||
run: |
|
||||
"""Helper script to get the actual branch name, docker safe"""
|
||||
import os
|
||||
from time import time
|
||||
|
||||
env_pr_branch = "GITHUB_HEAD_REF"
|
||||
default_branch = "GITHUB_REF"
|
||||
sha = "GITHUB_SHA"
|
||||
|
||||
branch_name = os.environ[default_branch]
|
||||
if os.environ.get(env_pr_branch, "") != "":
|
||||
branch_name = os.environ[env_pr_branch]
|
||||
|
||||
should_build = str(os.environ.get("DOCKER_USERNAME", "") != "").lower()
|
||||
|
||||
print("##[set-output name=branchName]%s" % branch_name)
|
||||
print(
|
||||
"##[set-output name=branchNameContainer]%s"
|
||||
% branch_name.replace("refs/heads/", "").replace("/", "-")
|
||||
)
|
||||
print("##[set-output name=timestamp]%s" % int(time()))
|
||||
print("##[set-output name=sha]%s" % os.environ[sha])
|
||||
print("##[set-output name=shouldBuild]%s" % should_build)
|
45
.github/actions/setup/action.yml
vendored
Normal file
45
.github/actions/setup/action.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
name: 'Setup authentik testing environemnt'
|
||||
description: 'Setup authentik testing environemnt'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install poetry
|
||||
shell: bash
|
||||
run: |
|
||||
pipx install poetry || true
|
||||
sudo apt update
|
||||
sudo apt install -y libxmlsec1-dev pkg-config gettext
|
||||
- name: Setup python and restore poetry
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'poetry'
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3.1.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Setup dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
docker-compose -f .github/actions/setup/docker-compose.yml up -d
|
||||
poetry env use python3.10
|
||||
poetry install
|
||||
npm install -g pyright@1.1.136
|
||||
- name: Generate config
|
||||
shell: poetry run python {0}
|
||||
run: |
|
||||
from authentik.lib.generators import generate_id
|
||||
from yaml import safe_dump
|
||||
|
||||
with open("local.env.yml", "w") as _config:
|
||||
safe_dump(
|
||||
{
|
||||
"log_level": "debug",
|
||||
"secret_key": generate_id(),
|
||||
},
|
||||
_config,
|
||||
default_flow_style=False,
|
||||
)
|
131
.github/workflows/ci-main.yml
vendored
131
.github/workflows/ci-main.yml
vendored
@ -32,35 +32,16 @@ jobs:
|
||||
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'
|
||||
- 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
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run job
|
||||
run: poetry run make ci-${{ matrix.job }}
|
||||
test-migrations:
|
||||
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: scripts/ci_prepare.sh
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run migrations
|
||||
run: poetry run python -m lifecycle.migrate
|
||||
test-migrations-from-stable:
|
||||
@ -69,17 +50,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- 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: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: checkout stable
|
||||
run: |
|
||||
# Copy current, latest config to local
|
||||
@ -89,13 +61,8 @@ jobs:
|
||||
git checkout $(git describe --abbrev=0 --match 'version/*')
|
||||
rm -rf .github/ scripts/
|
||||
mv ../.github ../scripts .
|
||||
- name: prepare
|
||||
env:
|
||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||
run: |
|
||||
scripts/ci_prepare.sh
|
||||
# install anyways since stable will have different dependencies
|
||||
poetry install
|
||||
- name: Setup authentik env (ensure stable deps are installed)
|
||||
uses: ./.github/actions/setup
|
||||
- name: run migrations to stable
|
||||
run: poetry run python -m lifecycle.migrate
|
||||
- name: checkout current code
|
||||
@ -103,28 +70,19 @@ jobs:
|
||||
set -x
|
||||
git fetch
|
||||
git reset --hard HEAD
|
||||
git clean -d -fx .
|
||||
git checkout $GITHUB_SHA
|
||||
poetry install
|
||||
- name: prepare
|
||||
env:
|
||||
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
|
||||
run: scripts/ci_prepare.sh
|
||||
- name: Setup authentik env (ensure latest deps are installed)
|
||||
uses: ./.github/actions/setup
|
||||
- name: migrate to latest
|
||||
run: poetry run python -m lifecycle.migrate
|
||||
test-unittest:
|
||||
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: scripts/ci_prepare.sh
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- uses: testspace-com/setup-testspace@v1
|
||||
with:
|
||||
domain: ${{github.repository_owner}}
|
||||
@ -137,21 +95,13 @@ jobs:
|
||||
run: |
|
||||
testspace [unittest]unittest.xml --link=codecov
|
||||
- if: ${{ always() }}
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v3
|
||||
test-integration:
|
||||
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: scripts/ci_prepare.sh
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- uses: testspace-com/setup-testspace@v1
|
||||
with:
|
||||
domain: ${{github.repository_owner}}
|
||||
@ -166,33 +116,21 @@ jobs:
|
||||
run: |
|
||||
testspace [integration]unittest.xml --link=codecov
|
||||
- if: ${{ always() }}
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v3
|
||||
test-e2e-provider:
|
||||
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
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- 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 }}
|
||||
- name: Setup authentik env
|
||||
run: |
|
||||
scripts/ci_prepare.sh
|
||||
docker-compose -f tests/e2e/docker-compose.yml up -d
|
||||
- id: cache-web
|
||||
uses: actions/cache@v2.1.7
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
||||
@ -211,33 +149,21 @@ jobs:
|
||||
run: |
|
||||
testspace [e2e-provider]unittest.xml --link=codecov
|
||||
- if: ${{ always() }}
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v3
|
||||
test-e2e-rest:
|
||||
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
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- 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 }}
|
||||
- name: Setup authentik env
|
||||
run: |
|
||||
scripts/ci_prepare.sh
|
||||
docker-compose -f tests/e2e/docker-compose.yml up -d
|
||||
- id: cache-web
|
||||
uses: actions/cache@v2.1.7
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
||||
@ -256,7 +182,7 @@ jobs:
|
||||
run: |
|
||||
testspace [e2e-rest]unittest.xml --link=codecov
|
||||
- if: ${{ always() }}
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v3
|
||||
ci-core-mark:
|
||||
needs:
|
||||
- lint
|
||||
@ -288,8 +214,7 @@ jobs:
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
run: |
|
||||
python ./scripts/gh_env.py
|
||||
uses: ./.github/actions/docker-setup
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v1
|
||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
|
20
.github/workflows/ci-outpost.yml
vendored
20
.github/workflows/ci-outpost.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- name: Run linter
|
||||
@ -34,17 +34,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
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
|
||||
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./...
|
||||
ci-outpost-mark:
|
||||
needs:
|
||||
- lint-golint
|
||||
@ -73,10 +68,9 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: prepare variables
|
||||
id: ev
|
||||
uses: ./.github/actions/docker-setup
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
run: |
|
||||
python ./scripts/gh_env.py
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v1
|
||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
@ -111,10 +105,10 @@ jobs:
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.0.0
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -130,7 +124,7 @@ jobs:
|
||||
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
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
path: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
|
8
.github/workflows/ci-web.yml
vendored
8
.github/workflows/ci-web.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.0.0
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.0.0
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.0.0
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -79,7 +79,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.0.0
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
|
16
.github/workflows/release-publish.yml
vendored
16
.github/workflows/release-publish.yml
vendored
@ -30,9 +30,9 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik:2022.3.3,
|
||||
beryju/authentik:2022.4.1,
|
||||
beryju/authentik:latest,
|
||||
ghcr.io/goauthentik/server:2022.3.3,
|
||||
ghcr.io/goauthentik/server:2022.4.1,
|
||||
ghcr.io/goauthentik/server:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@ -46,7 +46,7 @@ jobs:
|
||||
- ldap
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- name: Set up QEMU
|
||||
@ -69,9 +69,9 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik-${{ matrix.type }}:2022.3.3,
|
||||
beryju/authentik-${{ matrix.type }}:2022.4.1,
|
||||
beryju/authentik-${{ matrix.type }}:latest,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.3.3,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.4.1,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@ -88,10 +88,10 @@ jobs:
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.0.0
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -152,7 +152,7 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
version: authentik@2022.3.3
|
||||
version: authentik@2022.4.1
|
||||
environment: beryjuorg-prod
|
||||
sourcemaps: './web/dist'
|
||||
url_prefix: '~/static/dist'
|
||||
|
26
.github/workflows/translation-compile.yml
vendored
26
.github/workflows/translation-compile.yml
vendored
@ -7,8 +7,6 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '/locale/'
|
||||
schedule:
|
||||
- cron: "0 */2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@ -21,23 +19,12 @@ jobs:
|
||||
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: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run compile
|
||||
run: poetry run ./manage.py compilemessages
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v3
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -47,10 +34,3 @@ jobs:
|
||||
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
|
||||
|
11
.github/workflows/web-api-publish.yml
vendored
11
.github/workflows/web-api-publish.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v3.0.0
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
with:
|
||||
node-version: '16'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
@ -29,7 +29,7 @@ jobs:
|
||||
export VERSION=`node -e 'console.log(require("../web-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v3
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -39,10 +39,3 @@ jobs:
|
||||
body: "web: Update Web API Client version"
|
||||
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
|
||||
|
@ -32,7 +32,7 @@ COPY ./go.sum /work/go.sum
|
||||
RUN go build -o /work/authentik ./cmd/server/main.go
|
||||
|
||||
# Stage 4: Run
|
||||
FROM docker.io/python:3.10.3-slim-bullseye
|
||||
FROM docker.io/python:3.10.4-slim-bullseye
|
||||
|
||||
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.
|
||||
|
5
Makefile
5
Makefile
@ -56,13 +56,12 @@ gen-web:
|
||||
docker run \
|
||||
--rm -v ${PWD}:/local \
|
||||
--user ${UID}:${GID} \
|
||||
openapitools/openapi-generator-cli generate \
|
||||
openapitools/openapi-generator-cli:v6.0.0-beta generate \
|
||||
-i /local/schema.yml \
|
||||
-g typescript-fetch \
|
||||
-o /local/web-api \
|
||||
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=@goauthentik/api,npmVersion=${NPM_VERSION}
|
||||
mkdir -p web/node_modules/@goauthentik/api
|
||||
python -m scripts.web_api_esm
|
||||
\cp -fv scripts/web_api_readme.md web-api/README.md
|
||||
cd web-api && npm i
|
||||
\cp -rfv web-api/* web/node_modules/@goauthentik/api
|
||||
@ -75,7 +74,7 @@ gen-outpost:
|
||||
docker run \
|
||||
--rm -v ${PWD}:/local \
|
||||
--user ${UID}:${GID} \
|
||||
openapitools/openapi-generator-cli:v5.2.1 generate \
|
||||
openapitools/openapi-generator-cli:v6.0.0-beta generate \
|
||||
-i /local/schema.yml \
|
||||
-g go \
|
||||
-o /local/api \
|
||||
|
@ -6,8 +6,8 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ---------- | ------------------ |
|
||||
| 2022.2.x | :white_check_mark: |
|
||||
| 2022.3.x | :white_check_mark: |
|
||||
| 2022.4.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2022.3.3"
|
||||
__version__ = "2022.4.1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -1,6 +1,4 @@
|
||||
"""API Authentication"""
|
||||
from base64 import b64decode
|
||||
from binascii import Error
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.conf import settings
|
||||
@ -16,38 +14,36 @@ from authentik.outposts.models import Outpost
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
def bearer_auth(raw_header: bytes) -> Optional[User]:
|
||||
"""raw_header in the Format of `Bearer dGVzdDp0ZXN0`"""
|
||||
auth_credentials = raw_header.decode()
|
||||
def validate_auth(header: bytes) -> str:
|
||||
"""Validate that the header is in a correct format,
|
||||
returns type and credentials"""
|
||||
auth_credentials = header.decode().strip()
|
||||
if auth_credentials == "" or " " not in auth_credentials:
|
||||
return None
|
||||
auth_type, _, auth_credentials = auth_credentials.partition(" ")
|
||||
if auth_type.lower() not in ["basic", "bearer"]:
|
||||
if auth_type.lower() != "bearer":
|
||||
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
|
||||
raise AuthenticationFailed("Unsupported authentication type")
|
||||
password = auth_credentials
|
||||
if auth_type.lower() == "basic":
|
||||
try:
|
||||
auth_credentials = b64decode(auth_credentials.encode()).decode()
|
||||
except (UnicodeDecodeError, Error):
|
||||
raise AuthenticationFailed("Malformed header")
|
||||
# Accept credentials with username and without
|
||||
if ":" in auth_credentials:
|
||||
_, _, password = auth_credentials.partition(":")
|
||||
else:
|
||||
password = auth_credentials
|
||||
if password == "": # nosec
|
||||
if auth_credentials == "": # nosec
|
||||
raise AuthenticationFailed("Malformed header")
|
||||
tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
|
||||
if not tokens.exists():
|
||||
user = token_secret_key(password)
|
||||
if not user:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
return user
|
||||
return auth_credentials
|
||||
|
||||
|
||||
def bearer_auth(raw_header: bytes) -> Optional[User]:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
auth_credentials = validate_auth(raw_header)
|
||||
if not auth_credentials:
|
||||
return None
|
||||
# first, check traditional tokens
|
||||
token = Token.filter_not_expired(key=auth_credentials, intent=TokenIntents.INTENT_API).first()
|
||||
if hasattr(LOCAL, "authentik"):
|
||||
LOCAL.authentik[KEY_AUTH_VIA] = "api_token"
|
||||
return tokens.first().user
|
||||
if token:
|
||||
return token.user
|
||||
user = token_secret_key(auth_credentials)
|
||||
if user:
|
||||
return user
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
|
||||
|
||||
def token_secret_key(value: str) -> Optional[User]:
|
||||
|
@ -14,12 +14,6 @@ from authentik.outposts.managed import OutpostManager
|
||||
class TestAPIAuth(TestCase):
|
||||
"""Test API Authentication"""
|
||||
|
||||
def test_valid_basic(self):
|
||||
"""Test valid token"""
|
||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user())
|
||||
auth = b64encode(f":{token.key}".encode()).decode()
|
||||
self.assertEqual(bearer_auth(f"Basic {auth}".encode()), token.user)
|
||||
|
||||
def test_valid_bearer(self):
|
||||
"""Test valid token"""
|
||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user())
|
||||
@ -30,16 +24,6 @@ class TestAPIAuth(TestCase):
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
bearer_auth("foo bar".encode())
|
||||
|
||||
def test_invalid_decode(self):
|
||||
"""Test invalid bas64"""
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
bearer_auth("Basic bar".encode())
|
||||
|
||||
def test_invalid_empty_password(self):
|
||||
"""Test invalid with empty password"""
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
bearer_auth("Basic :".encode())
|
||||
|
||||
def test_invalid_no_token(self):
|
||||
"""Test invalid with no token"""
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
|
@ -17,6 +17,7 @@ from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||
from structlog.stdlib import get_logger
|
||||
from structlog.testing import capture_logs
|
||||
|
||||
from authentik.admin.api.metrics import CoordinateSerializer
|
||||
from authentik.api.decorators import permission_required
|
||||
@ -25,6 +26,7 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import FilePathSerializer, FileUploadSerializer
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.models import EventAction
|
||||
from authentik.events.utils import sanitize_dict
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.types import PolicyResult
|
||||
@ -42,7 +44,7 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"""Application Serializer"""
|
||||
|
||||
launch_url = SerializerMethodField()
|
||||
provider_obj = ProviderSerializer(source="get_provider", required=False)
|
||||
provider_obj = ProviderSerializer(source="get_provider", required=False, read_only=True)
|
||||
|
||||
meta_icon = ReadOnlyField(source="get_meta_icon")
|
||||
|
||||
@ -66,6 +68,7 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"meta_description",
|
||||
"meta_publisher",
|
||||
"policy_engine_mode",
|
||||
"group",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"meta_icon": {"read_only": True},
|
||||
@ -83,6 +86,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
"meta_launch_url",
|
||||
"meta_description",
|
||||
"meta_publisher",
|
||||
"group",
|
||||
]
|
||||
lookup_field = "slug"
|
||||
ordering = ["name"]
|
||||
@ -132,12 +136,19 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
return HttpResponseBadRequest("for_user must be numerical")
|
||||
engine = PolicyEngine(application, for_user, request)
|
||||
engine.use_cache = False
|
||||
engine.build()
|
||||
result = engine.result
|
||||
with capture_logs() as logs:
|
||||
engine.build()
|
||||
result = engine.result
|
||||
response = PolicyTestResultSerializer(PolicyResult(False))
|
||||
if result.passing:
|
||||
response = PolicyTestResultSerializer(PolicyResult(True))
|
||||
if request.user.is_superuser:
|
||||
log_messages = []
|
||||
for log in logs:
|
||||
if log.get("process", "") == "PolicyProcess":
|
||||
continue
|
||||
log_messages.append(sanitize_dict(log))
|
||||
result.log_messages = log_messages
|
||||
response = PolicyTestResultSerializer(result)
|
||||
return Response(response.data)
|
||||
|
||||
|
@ -4,7 +4,7 @@ from json import loads
|
||||
from django.db.models.query import QuerySet
|
||||
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
|
||||
from django_filters.filterset import FilterSet
|
||||
from rest_framework.fields import CharField, JSONField
|
||||
from rest_framework.fields import CharField, IntegerField, JSONField
|
||||
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||
@ -46,11 +46,14 @@ class GroupSerializer(ModelSerializer):
|
||||
)
|
||||
parent_name = CharField(source="parent.name", read_only=True)
|
||||
|
||||
num_pk = IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Group
|
||||
fields = [
|
||||
"pk",
|
||||
"num_pk",
|
||||
"name",
|
||||
"is_superuser",
|
||||
"parent",
|
||||
|
@ -2,7 +2,7 @@
|
||||
from typing import Any
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
||||
from guardian.shortcuts import assign_perm, get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
@ -20,13 +20,14 @@ from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import model_to_dict
|
||||
from authentik.managed.api import ManagedSerializer
|
||||
|
||||
|
||||
class TokenSerializer(ManagedSerializer, ModelSerializer):
|
||||
"""Token Serializer"""
|
||||
|
||||
user_obj = UserSerializer(required=False, source="user")
|
||||
user_obj = UserSerializer(required=False, source="user", read_only=True)
|
||||
|
||||
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
|
||||
"""Ensure only API or App password tokens are created."""
|
||||
@ -110,10 +111,39 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
404: OpenApiResponse(description="Token not found or expired"),
|
||||
}
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
|
||||
# pylint: disable=unused-argument
|
||||
def view_key(self, request: Request, identifier: str) -> Response:
|
||||
"""Return token key and log access"""
|
||||
token: Token = self.get_object()
|
||||
Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request) # noqa # nosec
|
||||
return Response(TokenViewSerializer({"key": token.key}).data)
|
||||
|
||||
@permission_required("authentik_core.set_token_key")
|
||||
@extend_schema(
|
||||
request=inline_serializer(
|
||||
"TokenSetKey",
|
||||
{
|
||||
"key": CharField(),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
204: OpenApiResponse(description="Successfully changed key"),
|
||||
400: OpenApiResponse(description="Missing key"),
|
||||
404: OpenApiResponse(description="Token not found or expired"),
|
||||
},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||
# pylint: disable=unused-argument
|
||||
def set_key(self, request: Request, identifier: str) -> Response:
|
||||
"""Return token key and log access"""
|
||||
token: Token = self.get_object()
|
||||
key = request.POST.get("key")
|
||||
if not key:
|
||||
return Response(status=400)
|
||||
token.key = key
|
||||
token.save()
|
||||
Event.new(EventAction.MODEL_UPDATED, model=model_to_dict(token)).from_http(
|
||||
request
|
||||
) # noqa # nosec
|
||||
return Response(status=204)
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""User API Views"""
|
||||
from datetime import timedelta
|
||||
from json import loads
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.db.models.query import QuerySet
|
||||
@ -23,7 +23,7 @@ from drf_spectacular.utils import (
|
||||
)
|
||||
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
|
||||
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.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import (
|
||||
@ -96,14 +96,13 @@ class UserSerializer(ModelSerializer):
|
||||
|
||||
|
||||
class UserSelfSerializer(ModelSerializer):
|
||||
"""User Serializer for information a user can retrieve about themselves and
|
||||
update about themselves"""
|
||||
"""User Serializer for information a user can retrieve about themselves"""
|
||||
|
||||
is_superuser = BooleanField(read_only=True)
|
||||
avatar = CharField(read_only=True)
|
||||
groups = SerializerMethodField()
|
||||
uid = CharField(read_only=True)
|
||||
settings = DictField(source="attributes.settings", default=dict)
|
||||
settings = SerializerMethodField()
|
||||
|
||||
@extend_schema_field(
|
||||
ListSerializer(
|
||||
@ -121,6 +120,10 @@ class UserSelfSerializer(ModelSerializer):
|
||||
"pk": group.pk,
|
||||
}
|
||||
|
||||
def get_settings(self, user: User) -> dict[str, Any]:
|
||||
"""Get user settings with tenant and group settings applied"""
|
||||
return user.group_attributes(self._context["request"]).get("settings", {})
|
||||
|
||||
class Meta:
|
||||
|
||||
model = User
|
||||
@ -328,12 +331,14 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
# pylint: disable=invalid-name
|
||||
def me(self, request: Request) -> Response:
|
||||
"""Get information about current user"""
|
||||
context = {"request": request}
|
||||
serializer = SessionUserSerializer(
|
||||
data={"user": UserSelfSerializer(instance=request.user).data}
|
||||
data={"user": UserSelfSerializer(instance=request.user, context=context).data}
|
||||
)
|
||||
if SESSION_IMPERSONATE_USER in request._request.session:
|
||||
serializer.initial_data["original"] = UserSelfSerializer(
|
||||
instance=request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
||||
instance=request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER],
|
||||
context=context,
|
||||
).data
|
||||
return Response(serializer.initial_data)
|
||||
|
||||
|
@ -49,6 +49,7 @@ class TokenBackend(InbuiltBackend):
|
||||
# difference between an existing and a nonexistent user (#20760).
|
||||
User().set_password(password)
|
||||
return None
|
||||
# pylint: disable=no-member
|
||||
tokens = Token.filter_not_expired(
|
||||
user=user, key=password, intent=TokenIntents.INTENT_APP_PASSWORD
|
||||
)
|
||||
|
18
authentik/core/migrations/0019_application_group.py
Normal file
18
authentik/core/migrations/0019_application_group.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.0.3 on 2022-04-02 19:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0018_auto_20210330_1345_squashed_0028_alter_token_intent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="application",
|
||||
name="group",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
@ -36,6 +36,9 @@ from authentik.policies.models import PolicyBindingModel
|
||||
LOGGER = get_logger()
|
||||
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
|
||||
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
|
||||
USER_ATTRIBUTE_GENERATED = "goauthentik.io/user/generated"
|
||||
USER_ATTRIBUTE_EXPIRES = "goauthentik.io/user/expires"
|
||||
USER_ATTRIBUTE_DELETE_ON_LOGOUT = "goauthentik.io/user/delete-on-logout"
|
||||
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec
|
||||
USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username"
|
||||
@ -59,7 +62,7 @@ def default_token_key():
|
||||
"""Default token key"""
|
||||
# We use generate_id since the chars in the key should be easy
|
||||
# to use in Emails (for verification) and URLs (for recovery)
|
||||
return generate_id(128)
|
||||
return generate_id(int(CONFIG.y("default_token_length")))
|
||||
|
||||
|
||||
class Group(models.Model):
|
||||
@ -81,6 +84,13 @@ class Group(models.Model):
|
||||
)
|
||||
attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
@property
|
||||
def num_pk(self) -> int:
|
||||
"""Get a numerical, int32 ID for the group"""
|
||||
# int max is 2147483647 (10 digits) so 9 is the max usable
|
||||
# in the LDAP Outpost we use the last 5 chars so match here
|
||||
return int(str(self.pk.int)[:5])
|
||||
|
||||
def is_member(self, user: "User") -> bool:
|
||||
"""Recursively check if `user` is member of us, or any parent."""
|
||||
query = """
|
||||
@ -137,10 +147,12 @@ class User(GuardianUserMixin, AbstractUser):
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
def group_attributes(self) -> dict[str, Any]:
|
||||
def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
|
||||
"""Get a dictionary containing the attributes from all groups the user belongs to,
|
||||
including the users attributes"""
|
||||
final_attributes = {}
|
||||
if request and hasattr(request, "tenant"):
|
||||
always_merger.merge(final_attributes, request.tenant.attributes)
|
||||
for group in self.ak_groups.all().order_by("name"):
|
||||
always_merger.merge(final_attributes, group.attributes)
|
||||
always_merger.merge(final_attributes, self.attributes)
|
||||
@ -156,11 +168,11 @@ class User(GuardianUserMixin, AbstractUser):
|
||||
"""superuser == staff user"""
|
||||
return self.is_superuser # type: ignore
|
||||
|
||||
def set_password(self, password, signal=True):
|
||||
def set_password(self, raw_password, signal=True):
|
||||
if self.pk and signal:
|
||||
password_changed.send(sender=self, user=self, password=password)
|
||||
password_changed.send(sender=self, user=self, password=raw_password)
|
||||
self.password_change_date = now()
|
||||
return super().set_password(password)
|
||||
return super().set_password(raw_password)
|
||||
|
||||
def check_password(self, raw_password: str) -> bool:
|
||||
"""
|
||||
@ -257,6 +269,8 @@ class Application(PolicyBindingModel):
|
||||
|
||||
name = models.TextField(help_text=_("Application's display Name."))
|
||||
slug = models.SlugField(help_text=_("Internal application name, used in URLs."), unique=True)
|
||||
group = models.TextField(blank=True, default="")
|
||||
|
||||
provider = models.OneToOneField(
|
||||
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
|
||||
)
|
||||
|
@ -1,10 +1,18 @@
|
||||
"""authentik core tasks"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||
from django.core.cache import cache
|
||||
from django.utils.timezone import now
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, ExpiringModel
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_EXPIRES,
|
||||
USER_ATTRIBUTE_GENERATED,
|
||||
AuthenticatedSession,
|
||||
ExpiringModel,
|
||||
User,
|
||||
)
|
||||
from authentik.events.monitored_tasks import (
|
||||
MonitoredTask,
|
||||
TaskResult,
|
||||
@ -42,3 +50,24 @@ def clean_expired_models(self: MonitoredTask):
|
||||
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
|
||||
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
@prefill_task
|
||||
def clean_temporary_users(self: MonitoredTask):
|
||||
"""Remove temporary users created by SAML Sources"""
|
||||
_now = datetime.now()
|
||||
messages = []
|
||||
deleted_users = 0
|
||||
for user in User.objects.filter(**{f"attributes__{USER_ATTRIBUTE_GENERATED}": True}):
|
||||
if not user.attributes.get(USER_ATTRIBUTE_EXPIRES):
|
||||
continue
|
||||
delta: timedelta = _now - datetime.fromtimestamp(
|
||||
user.attributes.get(USER_ATTRIBUTE_EXPIRES)
|
||||
)
|
||||
if delta.total_seconds() > 0:
|
||||
LOGGER.debug("User is expired and will be deleted.", user=user, delta=delta)
|
||||
user.delete()
|
||||
deleted_users += 1
|
||||
messages.append(f"Successfully deleted {deleted_users} users.")
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
|
||||
|
@ -1,4 +1,6 @@
|
||||
"""Test Applications API"""
|
||||
from json import loads
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@ -46,7 +48,10 @@ class TestApplicationsAPI(APITestCase):
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(response.content.decode(), {"messages": [], "passing": True})
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["passing"], True)
|
||||
self.assertEqual(body["messages"], [])
|
||||
self.assertEqual(len(body["log_messages"]), 0)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:application-check-access",
|
||||
@ -54,7 +59,9 @@ class TestApplicationsAPI(APITestCase):
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(response.content.decode(), {"messages": ["dummy"], "passing": False})
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["passing"], False)
|
||||
self.assertEqual(body["messages"], ["dummy"])
|
||||
|
||||
def test_list(self):
|
||||
"""Test list operation without superuser_full_list"""
|
||||
@ -77,6 +84,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
"pk": str(self.allowed.pk),
|
||||
"name": "allowed",
|
||||
"slug": "allowed",
|
||||
"group": "",
|
||||
"provider": self.provider.pk,
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
@ -124,6 +132,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
"pk": str(self.allowed.pk),
|
||||
"name": "allowed",
|
||||
"slug": "allowed",
|
||||
"group": "",
|
||||
"provider": self.provider.pk,
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
@ -150,6 +159,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
"meta_icon": None,
|
||||
"meta_launch_url": "",
|
||||
"meta_publisher": "",
|
||||
"group": "",
|
||||
"name": "denied",
|
||||
"pk": str(self.denied.pk),
|
||||
"policy_engine_mode": "any",
|
||||
|
50
authentik/core/tests/test_tasks.py
Normal file
50
authentik/core/tests/test_tasks.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Test tasks"""
|
||||
from time import mktime
|
||||
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_EXPIRES,
|
||||
USER_ATTRIBUTE_GENERATED,
|
||||
Token,
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.core.tasks import clean_expired_models, clean_temporary_users
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestTasks(APITestCase):
|
||||
"""Test token API"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.create(username="testuser")
|
||||
self.admin = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_token_expire(self):
|
||||
"""Test Token expire task"""
|
||||
token: Token = Token.objects.create(
|
||||
expires=now(), user=get_anonymous_user(), intent=TokenIntents.INTENT_API
|
||||
)
|
||||
key = token.key
|
||||
clean_expired_models.delay().get()
|
||||
token.refresh_from_db()
|
||||
self.assertNotEqual(key, token.key)
|
||||
|
||||
def test_clean_temporary_users(self):
|
||||
"""Test clean_temporary_users task"""
|
||||
username = generate_id
|
||||
User.objects.create(
|
||||
username=username,
|
||||
attributes={
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
USER_ATTRIBUTE_EXPIRES: mktime(now().timetuple()),
|
||||
},
|
||||
)
|
||||
clean_temporary_users.delay().get()
|
||||
self.assertFalse(User.objects.filter(username=username))
|
@ -2,12 +2,10 @@
|
||||
from json import loads
|
||||
|
||||
from django.urls.base import reverse
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
|
||||
from authentik.core.tasks import clean_expired_models
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
|
||||
|
||||
@ -53,16 +51,6 @@ class TestTokenAPI(APITestCase):
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.expiring, False)
|
||||
|
||||
def test_token_expire(self):
|
||||
"""Test Token expire task"""
|
||||
token: Token = Token.objects.create(
|
||||
expires=now(), user=get_anonymous_user(), intent=TokenIntents.INTENT_API
|
||||
)
|
||||
key = token.key
|
||||
clean_expired_models.delay().get()
|
||||
token.refresh_from_db()
|
||||
self.assertNotEqual(key, token.key)
|
||||
|
||||
def test_list(self):
|
||||
"""Test Token List (Test normal authentication)"""
|
||||
token_should: Token = Token.objects.create(
|
||||
|
@ -67,9 +67,9 @@ def certificate_discovery(self: MonitoredTask):
|
||||
private_keys[cert_name] = ensure_private_key_valid(body)
|
||||
else:
|
||||
certs[cert_name] = ensure_certificate_valid(body)
|
||||
discovered += 1
|
||||
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:
|
||||
|
@ -93,6 +93,11 @@ def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
||||
final_dict[key] = value.hex
|
||||
elif isinstance(value, (HttpRequest, WSGIRequest)):
|
||||
continue
|
||||
elif isinstance(value, type):
|
||||
final_dict[key] = {
|
||||
"type": value.__name__,
|
||||
"module": value.__module__,
|
||||
}
|
||||
else:
|
||||
final_dict[key] = value
|
||||
return final_dict
|
||||
|
@ -442,9 +442,9 @@ class FlowErrorResponse(TemplateResponse):
|
||||
context = {}
|
||||
context["error"] = self.error
|
||||
if self._request.user and self._request.user.is_authenticated:
|
||||
if self._request.user.is_superuser or self._request.user.group_attributes().get(
|
||||
USER_ATTRIBUTE_DEBUG, False
|
||||
):
|
||||
if self._request.user.is_superuser or self._request.user.group_attributes(
|
||||
self._request
|
||||
).get(USER_ATTRIBUTE_DEBUG, False):
|
||||
context["tb"] = "".join(format_tb(self.error.__traceback__))
|
||||
return context
|
||||
|
||||
|
@ -71,3 +71,4 @@ default_user_change_username: true
|
||||
|
||||
gdpr_compliance: true
|
||||
cert_discovery_dir: /certs
|
||||
default_token_length: 128
|
||||
|
@ -45,7 +45,7 @@ def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]:
|
||||
LOGGER.warning("Attempted remote-ip override without token", fake_ip=fake_ip)
|
||||
return None
|
||||
user = tokens.first().user
|
||||
if not user.group_attributes().get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False):
|
||||
if not user.group_attributes(request).get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False):
|
||||
LOGGER.warning(
|
||||
"Remote-IP override: user doesn't have permission",
|
||||
user=user,
|
||||
|
@ -157,7 +157,7 @@ class DockerController(BaseController):
|
||||
# {'HostIp': '::', 'HostPort': '389'}
|
||||
# ]}
|
||||
# If no ports are mapped (either mapping disabled, or host network)
|
||||
if not container.ports:
|
||||
if not container.ports or not self.outpost.config.docker_map_ports:
|
||||
return False
|
||||
for port in self.deployment_ports:
|
||||
key = f"{port.inner_port or port.port}/{port.protocol.lower()}"
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""Serializer for policy execution"""
|
||||
from rest_framework.fields import BooleanField, CharField, JSONField, ListField
|
||||
from rest_framework.fields import BooleanField, CharField, DictField, JSONField, ListField
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer, is_dict
|
||||
@ -18,3 +18,4 @@ class PolicyTestResultSerializer(PassiveSerializer):
|
||||
|
||||
passing = BooleanField()
|
||||
messages = ListField(child=CharField(), read_only=True)
|
||||
log_messages = ListField(child=DictField(), read_only=True)
|
||||
|
@ -11,11 +11,13 @@ from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
from structlog.testing import capture_logs
|
||||
|
||||
from authentik.api.decorators import permission_required
|
||||
from authentik.core.api.applications import user_app_cache_key
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import CacheSerializer, MetaNameSerializer, TypeCreateSerializer
|
||||
from authentik.events.utils import sanitize_dict
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer
|
||||
from authentik.policies.models import Policy, PolicyBinding
|
||||
@ -166,6 +168,13 @@ class PolicyViewSet(
|
||||
p_request.context = test_params.validated_data.get("context", {})
|
||||
|
||||
proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None)
|
||||
result = proc.execute()
|
||||
with capture_logs() as logs:
|
||||
result = proc.execute()
|
||||
log_messages = []
|
||||
for log in logs:
|
||||
if log.get("process", "") == "PolicyProcess":
|
||||
continue
|
||||
log_messages.append(sanitize_dict(log))
|
||||
result.log_messages = log_messages
|
||||
response = PolicyTestResultSerializer(result)
|
||||
return Response(response.data)
|
||||
|
@ -33,8 +33,8 @@ class AccessDeniedResponse(TemplateResponse):
|
||||
# either superuser or has USER_ATTRIBUTE_DEBUG set
|
||||
if self.policy_result:
|
||||
if self._request.user and self._request.user.is_authenticated:
|
||||
if self._request.user.is_superuser or self._request.user.group_attributes().get(
|
||||
USER_ATTRIBUTE_DEBUG, False
|
||||
):
|
||||
if self._request.user.is_superuser or self._request.user.group_attributes(
|
||||
self._request
|
||||
).get(USER_ATTRIBUTE_DEBUG, False):
|
||||
context["policy_result"] = self.policy_result
|
||||
return context
|
||||
|
@ -36,7 +36,7 @@ class DummyPolicy(Policy):
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""Wait random time then return result"""
|
||||
wait = SystemRandom().randrange(self.wait_min, self.wait_max)
|
||||
LOGGER.debug("Policy waiting", policy=self, delay=wait)
|
||||
LOGGER.info("Policy waiting", policy=self, delay=wait)
|
||||
sleep(wait)
|
||||
return PolicyResult(self.result, "dummy")
|
||||
|
||||
|
@ -9,7 +9,7 @@ from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
|
||||
class ExpressionPolicy(Policy):
|
||||
"""Execute arbitrary Python code to implement custom checks and validation."""
|
||||
"""Execute arbitrary Python code to implement custom checks and validation."""
|
||||
|
||||
expression = models.TextField()
|
||||
|
||||
|
@ -90,6 +90,7 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
"P_ENG(proc): Running policy",
|
||||
policy=self.binding.policy,
|
||||
user=self.request.user,
|
||||
# this is used for filtering in access checking where logs are sent to the admin
|
||||
process="PolicyProcess",
|
||||
)
|
||||
try:
|
||||
@ -121,6 +122,7 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
"P_ENG(proc): finished and cached ",
|
||||
policy=self.binding.policy,
|
||||
result=policy_result,
|
||||
# this is used for filtering in access checking where logs are sent to the admin
|
||||
process="PolicyProcess",
|
||||
passing=policy_result.passing,
|
||||
user=self.request.user,
|
||||
|
@ -1,4 +1,6 @@
|
||||
"""Test policies API"""
|
||||
from json import loads
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@ -23,7 +25,9 @@ class TestPoliciesAPI(APITestCase):
|
||||
"user": self.user.pk,
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(response.content.decode(), {"passing": True, "messages": ["dummy"]})
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["passing"], True)
|
||||
self.assertEqual(body["messages"], ["dummy"])
|
||||
|
||||
def test_types(self):
|
||||
"""Test Policy's types endpoint"""
|
||||
|
@ -67,12 +67,15 @@ class PolicyResult:
|
||||
source_binding: Optional["PolicyBinding"]
|
||||
source_results: Optional[list["PolicyResult"]]
|
||||
|
||||
log_messages: Optional[list[dict]]
|
||||
|
||||
def __init__(self, passing: bool, *messages: str):
|
||||
super().__init__()
|
||||
self.passing = passing
|
||||
self.messages = messages
|
||||
self.source_binding = None
|
||||
self.source_results = []
|
||||
self.log_messages = []
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
@ -14,7 +14,7 @@ from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.types import PolicyResult
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -103,11 +103,16 @@ class PolicyAccessView(AccessMixin, View):
|
||||
response.policy_result = result
|
||||
return response
|
||||
|
||||
def modify_policy_request(self, request: PolicyRequest) -> PolicyRequest:
|
||||
"""optionally modify the policy request"""
|
||||
return request
|
||||
|
||||
def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
|
||||
"""Check if user has access to application."""
|
||||
user = user or self.request.user
|
||||
policy_engine = PolicyEngine(self.application, user or self.request.user, self.request)
|
||||
policy_engine.use_cache = False
|
||||
policy_engine.request = self.modify_policy_request(policy_engine.request)
|
||||
policy_engine.build()
|
||||
result = policy_engine.result
|
||||
LOGGER.debug(
|
||||
|
@ -34,6 +34,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
"verification_keys",
|
||||
]
|
||||
|
||||
|
||||
|
@ -1,8 +1,14 @@
|
||||
"""OAuth/OpenID Constants"""
|
||||
|
||||
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
||||
GRANT_TYPE_IMPLICIT = "implicit"
|
||||
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||
GRANT_TYPE_PASSWORD = "password" # nosec
|
||||
|
||||
CLIENT_ASSERTION_TYPE = "client_assertion_type"
|
||||
CLIENT_ASSERTION = "client_assertion"
|
||||
CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||
|
||||
PROMPT_NONE = "none"
|
||||
PROMPT_CONSNET = "consent"
|
||||
|
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.0.3 on 2022-03-29 19:37
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0003_certificatekeypair_managed"),
|
||||
("authentik_providers_oauth2", "0008_rename_rsa_key_oauth2provider_signing_key_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="verification_keys",
|
||||
field=models.ManyToManyField(
|
||||
help_text="JWTs created with the configured certificates can authenticate with this provider.",
|
||||
related_name="+",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
verbose_name="Allowed certificates for JWT-based client_credentials",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="signing_key",
|
||||
field=models.ForeignKey(
|
||||
help_text="Key used to sign the tokens. Only required when JWT Algorithm is set to RS256.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
verbose_name="Signing Key",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.0.3 on 2022-03-31 18:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0003_certificatekeypair_managed"),
|
||||
("authentik_providers_oauth2", "0009_oauth2provider_verification_keys_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="verification_keys",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="JWTs created with the configured certificates can authenticate with this provider.",
|
||||
related_name="+",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
verbose_name="Allowed certificates for JWT-based client_credentials",
|
||||
),
|
||||
),
|
||||
]
|
@ -97,7 +97,7 @@ class JWTAlgorithms(models.TextChoices):
|
||||
|
||||
HS256 = "HS256", _("HS256 (Symmetric Encryption)")
|
||||
RS256 = "RS256", _("RS256 (Asymmetric Encryption)")
|
||||
EC256 = "EC256", _("EC256 (Asymmetric Encryption)")
|
||||
ES256 = "ES256", _("ES256 (Asymmetric Encryption)")
|
||||
|
||||
|
||||
class ScopeMapping(PropertyMapping):
|
||||
@ -212,7 +212,7 @@ class OAuth2Provider(Provider):
|
||||
|
||||
signing_key = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
verbose_name=_("RSA Key"),
|
||||
verbose_name=_("Signing Key"),
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
help_text=_(
|
||||
@ -220,6 +220,17 @@ class OAuth2Provider(Provider):
|
||||
),
|
||||
)
|
||||
|
||||
verification_keys = models.ManyToManyField(
|
||||
CertificateKeyPair,
|
||||
verbose_name=_("Allowed certificates for JWT-based client_credentials"),
|
||||
help_text=_(
|
||||
"JWTs created with the configured certificates can authenticate with this provider."
|
||||
),
|
||||
related_name="+",
|
||||
default=None,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
def create_refresh_token(
|
||||
self, user: User, scope: list[str], request: HttpRequest
|
||||
) -> "RefreshToken":
|
||||
@ -244,7 +255,7 @@ class OAuth2Provider(Provider):
|
||||
if isinstance(private_key, RSAPrivateKey):
|
||||
return key.key_data, JWTAlgorithms.RS256
|
||||
if isinstance(private_key, EllipticCurvePrivateKey):
|
||||
return key.key_data, JWTAlgorithms.EC256
|
||||
return key.key_data, JWTAlgorithms.ES256
|
||||
raise Exception(f"Invalid private key type: {type(private_key)}")
|
||||
|
||||
def get_issuer(self, request: HttpRequest) -> Optional[str]:
|
||||
|
@ -84,26 +84,6 @@ class TestTokenClientCredentials(OAuthTestCase):
|
||||
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
|
||||
)
|
||||
|
||||
def test_non_sa(self):
|
||||
"""test non service-account"""
|
||||
self.user.attributes[USER_ATTRIBUTE_SA] = False
|
||||
self.user.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": SCOPE_OPENID,
|
||||
"client_id": self.provider.client_id,
|
||||
"username": "sa",
|
||||
"password": self.token.key,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
|
||||
)
|
||||
|
||||
def test_no_provider(self):
|
||||
"""test no provider"""
|
||||
self.app.provider = None
|
||||
|
@ -0,0 +1,206 @@
|
||||
"""Test token view"""
|
||||
from datetime import datetime, timedelta
|
||||
from json import loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from jwt import decode
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.constants import (
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
class TestTokenClientCredentialsJWT(OAuthTestCase):
|
||||
"""Test token (client_credentials, with JWT) view"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
ObjectManager().run()
|
||||
self.factory = RequestFactory()
|
||||
self.cert = create_test_cert()
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=self.cert,
|
||||
)
|
||||
self.provider.verification_keys.set([self.cert])
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
self.user = create_test_admin_user("sa")
|
||||
self.user.attributes[USER_ATTRIBUTE_SA] = True
|
||||
self.user.save()
|
||||
|
||||
def test_invalid_type(self):
|
||||
"""test invalid type"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "foo",
|
||||
"client_assertion": "foo.bar",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_jwt(self):
|
||||
"""test invalid JWT"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": "foo.bar",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_signautre(self):
|
||||
"""test invalid JWT"""
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token + "foo",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_expired(self):
|
||||
"""test invalid JWT"""
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() - timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_no_app(self):
|
||||
"""test invalid JWT"""
|
||||
self.app.provider = None
|
||||
self.app.save()
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_access_denied(self):
|
||||
"""test invalid JWT"""
|
||||
group = Group.objects.create(name="foo")
|
||||
PolicyBinding.objects.create(
|
||||
group=group,
|
||||
target=self.app,
|
||||
order=0,
|
||||
)
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_successful(self):
|
||||
"""test successful"""
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], "bearer")
|
||||
_, alg = self.provider.get_jwt_key()
|
||||
jwt = decode(
|
||||
body["access_token"],
|
||||
key=self.provider.signing_key.public_key,
|
||||
algorithms=[alg],
|
||||
audience=self.provider.client_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
jwt["given_name"], "Autogenerated user from application test (client credentials JWT)"
|
||||
)
|
||||
self.assertEqual(jwt["preferred_username"], "test-foo")
|
@ -27,6 +27,7 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.types import PolicyRequest
|
||||
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
||||
from authentik.providers.oauth2.constants import (
|
||||
PROMPT_CONSNET,
|
||||
@ -438,6 +439,16 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
self.provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
||||
self.application = self.provider.application
|
||||
|
||||
def modify_policy_request(self, request: PolicyRequest) -> PolicyRequest:
|
||||
request.context["oauth_scopes"] = self.params.scope
|
||||
request.context["oauth_grant_type"] = self.params.grant_type
|
||||
request.context["oauth_code_challenge"] = self.params.code_challenge
|
||||
request.context["oauth_code_challenge_method"] = self.params.code_challenge_method
|
||||
request.context["oauth_max_age"] = self.params.max_age
|
||||
request.context["oauth_redirect_uri"] = self.params.redirect_uri
|
||||
request.context["oauth_response_type"] = self.params.response_type
|
||||
return request
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Start FlowPLanner, return to flow executor shell"""
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""authentik pretend GitHub Views"""
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.utils.text import slugify
|
||||
from django.views import View
|
||||
|
||||
from authentik.providers.oauth2.models import RefreshToken
|
||||
@ -66,4 +68,57 @@ class GitHubUserTeamsView(View):
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||
return JsonResponse([], safe=False)
|
||||
user = token.user
|
||||
|
||||
orgs_response = []
|
||||
for org in user.ak_groups.all():
|
||||
_org = {
|
||||
"id": org.num_pk,
|
||||
"node_id": "",
|
||||
"url": "",
|
||||
"html_url": "",
|
||||
"name": org.name,
|
||||
"slug": slugify(org.name),
|
||||
"description": "",
|
||||
"privacy": "",
|
||||
"permission": "",
|
||||
"members_url": "",
|
||||
"repositories_url": "",
|
||||
"parent": None,
|
||||
"members_count": 0,
|
||||
"repos_count": 0,
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"organization": {
|
||||
"login": slugify(request.tenant.branding_title),
|
||||
"id": 1,
|
||||
"node_id": "",
|
||||
"url": "",
|
||||
"repos_url": "",
|
||||
"events_url": "",
|
||||
"hooks_url": "",
|
||||
"issues_url": "",
|
||||
"members_url": "",
|
||||
"public_members_url": "",
|
||||
"avatar_url": "",
|
||||
"description": "",
|
||||
"name": request.tenant.branding_title,
|
||||
"company": "",
|
||||
"blog": "",
|
||||
"location": "",
|
||||
"email": "",
|
||||
"is_verified": True,
|
||||
"has_organization_projects": True,
|
||||
"has_repository_projects": True,
|
||||
"public_repos": 0,
|
||||
"public_gists": 0,
|
||||
"followers": 0,
|
||||
"following": 0,
|
||||
"html_url": "",
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"type": "Organization",
|
||||
},
|
||||
}
|
||||
orgs_response.append(_org)
|
||||
return JsonResponse(orgs_response, safe=False)
|
||||
|
@ -36,7 +36,6 @@ class JWKSView(View):
|
||||
|
||||
if signing_key:
|
||||
private_key = signing_key.private_key
|
||||
print(type(private_key))
|
||||
if isinstance(private_key, RSAPrivateKey):
|
||||
public_key: RSAPublicKey = private_key.public_key()
|
||||
public_numbers = public_key.public_numbers()
|
||||
@ -56,7 +55,7 @@ class JWKSView(View):
|
||||
response_data["keys"] = [
|
||||
{
|
||||
"kty": "EC",
|
||||
"alg": JWTAlgorithms.EC256,
|
||||
"alg": JWTAlgorithms.ES256,
|
||||
"use": "sig",
|
||||
"kid": signing_key.kid,
|
||||
"n": b64_enc(public_numbers.n),
|
||||
|
@ -11,15 +11,12 @@ from authentik.providers.oauth2.constants import (
|
||||
ACR_AUTHENTIK_DEFAULT,
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
GRANT_TYPE_IMPLICIT,
|
||||
GRANT_TYPE_PASSWORD,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
SCOPE_OPENID,
|
||||
)
|
||||
from authentik.providers.oauth2.models import (
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ResponseTypes, ScopeMapping
|
||||
from authentik.providers.oauth2.utils import cors_allow
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -78,8 +75,9 @@ class ProviderInfoView(View):
|
||||
"grant_types_supported": [
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
GrantTypes.IMPLICIT,
|
||||
GRANT_TYPE_IMPLICIT,
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
GRANT_TYPE_PASSWORD,
|
||||
],
|
||||
"id_token_signing_alg_values_supported": [supported_alg],
|
||||
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
||||
|
@ -5,22 +5,37 @@ from hashlib import sha256
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.timezone import datetime, now
|
||||
from django.views import View
|
||||
from jwt import InvalidTokenError, decode
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_SA, Application, Token, TokenIntents, User
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_EXPIRES,
|
||||
USER_ATTRIBUTE_GENERATED,
|
||||
Application,
|
||||
Token,
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.providers.oauth2.constants import (
|
||||
CLIENT_ASSERTION,
|
||||
CLIENT_ASSERTION_TYPE,
|
||||
CLIENT_ASSERTION_TYPE_JWT,
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
GRANT_TYPE_PASSWORD,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import TokenError, UserAuthError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
ClientTypes,
|
||||
JWTAlgorithms,
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
)
|
||||
@ -78,6 +93,18 @@ class TokenParams:
|
||||
code_verifier=request.POST.get("code_verifier"),
|
||||
)
|
||||
|
||||
def __check_policy_access(self, app: Application, request: HttpRequest, **kwargs):
|
||||
engine = PolicyEngine(app, self.user, request)
|
||||
engine.request.context["oauth_scopes"] = self.scope
|
||||
engine.request.context["oauth_grant_type"] = self.grant_type
|
||||
engine.request.context["oauth_code_verifier"] = self.code_verifier
|
||||
engine.request.context.update(kwargs)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if not result.passing:
|
||||
LOGGER.info("User not authenticated for application", user=self.user, app=app)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
|
||||
if (
|
||||
@ -94,7 +121,7 @@ class TokenParams:
|
||||
self.__post_init_code(raw_code)
|
||||
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||
self.__post_init_refresh(raw_token, request)
|
||||
elif self.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS:
|
||||
elif self.grant_type in [GRANT_TYPE_CLIENT_CREDENTIALS, GRANT_TYPE_PASSWORD]:
|
||||
self.__post_init_client_credentials(request)
|
||||
else:
|
||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||
@ -187,6 +214,8 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __post_init_client_credentials(self, request: HttpRequest):
|
||||
if request.POST.get(CLIENT_ASSERTION_TYPE, "") != "":
|
||||
return self.__post_init_client_credentials_jwt(request)
|
||||
# Authenticate user based on credentials
|
||||
username = request.POST.get("username")
|
||||
password = request.POST.get("password")
|
||||
@ -199,10 +228,6 @@ class TokenParams:
|
||||
if not token or token.user.uid != user.uid:
|
||||
raise TokenError("invalid_grant")
|
||||
self.user = user
|
||||
if not self.user.attributes.get(USER_ATTRIBUTE_SA, False):
|
||||
# Non-service accounts are not allowed
|
||||
LOGGER.info("Non-service-account tried to use client credentials", user=self.user)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
Event.new(
|
||||
action=EventAction.LOGIN,
|
||||
@ -216,13 +241,74 @@ class TokenParams:
|
||||
app = Application.objects.filter(provider=self.provider).first()
|
||||
if not app or not app.provider:
|
||||
raise TokenError("invalid_grant")
|
||||
engine = PolicyEngine(app, self.user, request)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if not result.passing:
|
||||
LOGGER.info("User not authenticated for application", user=self.user, app=app)
|
||||
self.__check_policy_access(app, request)
|
||||
return None
|
||||
|
||||
def __post_init_client_credentials_jwt(self, request: HttpRequest):
|
||||
assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "")
|
||||
if assertion_type != CLIENT_ASSERTION_TYPE_JWT:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
client_secret = request.POST.get("client_secret", None)
|
||||
assertion = request.POST.get(CLIENT_ASSERTION, client_secret)
|
||||
if not assertion:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
token = None
|
||||
for cert in self.provider.verification_keys.all():
|
||||
LOGGER.debug("verifying jwt with key", key=cert.name)
|
||||
cert: CertificateKeyPair
|
||||
public_key = cert.certificate.public_key()
|
||||
if cert.private_key:
|
||||
public_key = cert.private_key.public_key()
|
||||
try:
|
||||
token = decode(
|
||||
assertion,
|
||||
public_key,
|
||||
algorithms=[JWTAlgorithms.RS256, JWTAlgorithms.ES256],
|
||||
options={
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
except (InvalidTokenError, ValueError, TypeError) as last_exc:
|
||||
LOGGER.warning("failed to validate jwt", last_exc=last_exc)
|
||||
if not token:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
if "exp" in token:
|
||||
exp = datetime.fromtimestamp(token["exp"])
|
||||
# Non-timezone aware check since we assume `exp` is in UTC
|
||||
if datetime.now() >= exp:
|
||||
LOGGER.info("JWT token expired")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
app = Application.objects.filter(provider=self.provider).first()
|
||||
if not app or not app.provider:
|
||||
LOGGER.info("client_credentials grant for provider without application")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
self.__check_policy_access(app, request, oauth_jwt=token)
|
||||
|
||||
self.user, _ = User.objects.update_or_create(
|
||||
username=f"{self.provider.name}-{token.get('sub')}",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
USER_ATTRIBUTE_EXPIRES: token.get("exp"),
|
||||
},
|
||||
"last_login": now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials JWT)",
|
||||
},
|
||||
)
|
||||
|
||||
Event.new(
|
||||
action=EventAction.LOGIN,
|
||||
PLAN_CONTEXT_METHOD="jwt",
|
||||
PLAN_CONTEXT_METHOD_ARGS={
|
||||
"jwt": token,
|
||||
},
|
||||
).from_http(request, user=self.user)
|
||||
|
||||
|
||||
class TokenView(View):
|
||||
"""Generate tokens for clients"""
|
||||
|
@ -8,7 +8,7 @@ SCOPE_AK_PROXY_EXPRESSION = """
|
||||
# which are used for example for the HTTP-Basic Authentication mapping.
|
||||
return {
|
||||
"ak_proxy": {
|
||||
"user_attributes": request.user.group_attributes()
|
||||
"user_attributes": request.user.group_attributes(request)
|
||||
}
|
||||
}"""
|
||||
|
||||
|
@ -30,7 +30,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
|
||||
# Since go does not consider localhost with http a secure origin
|
||||
# we can't set the secure flag.
|
||||
user_agent = request.META.get("HTTP_USER_AGENT", "")
|
||||
if user_agent.startswith("authentik-outpost@") or "safari" in user_agent.lower():
|
||||
if user_agent.startswith("goauthentik.io/outpost/") or "safari" in user_agent.lower():
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
@ -345,6 +345,11 @@ CELERY_BEAT_SCHEDULE = {
|
||||
"schedule": crontab(hour="*/24", minute=0),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
"user_cleanup": {
|
||||
"task": "authentik.core.tasks.clean_temporary_users",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
||||
CELERY_TASK_CREATE_MISSING_QUEUES = True
|
||||
CELERY_TASK_DEFAULT_QUEUE = "authentik"
|
||||
|
@ -6,7 +6,7 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthentikSourceSAMLConfig(AppConfig):
|
||||
"""authentik saml_idp app config"""
|
||||
"""authentik saml source app config"""
|
||||
|
||||
name = "authentik.sources.saml"
|
||||
label = "authentik_sources_saml"
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""authentik saml source processor"""
|
||||
from base64 import b64decode
|
||||
from time import mktime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import xmlsec
|
||||
@ -7,9 +8,16 @@ from defusedxml.lxml import fromstring
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.timezone import now
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_DELETE_ON_LOGOUT,
|
||||
USER_ATTRIBUTE_EXPIRES,
|
||||
USER_ATTRIBUTE_GENERATED,
|
||||
USER_ATTRIBUTE_SOURCES,
|
||||
User,
|
||||
)
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.flows.planner import (
|
||||
PLAN_CONTEXT_PENDING_USER,
|
||||
@ -19,6 +27,7 @@ from authentik.flows.planner import (
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.policies.utils import delete_none_keys
|
||||
from authentik.sources.saml.exceptions import (
|
||||
@ -124,9 +133,19 @@ class ResponseProcessor:
|
||||
on logout and periodically."""
|
||||
# Create a temporary User
|
||||
name_id = self._get_name_id().text
|
||||
expiry = mktime(
|
||||
(now() + timedelta_from_string(self._source.temporary_user_delete_after)).timetuple()
|
||||
)
|
||||
user: User = User.objects.create(
|
||||
username=name_id,
|
||||
attributes={"saml": {"source": self._source.pk.hex, "delete_on_logout": True}},
|
||||
attributes={
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
USER_ATTRIBUTE_SOURCES: [
|
||||
self._source.name,
|
||||
],
|
||||
USER_ATTRIBUTE_DELETE_ON_LOGOUT: True,
|
||||
USER_ATTRIBUTE_EXPIRES: expiry,
|
||||
},
|
||||
)
|
||||
LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
|
||||
user.set_unusable_password()
|
||||
|
@ -1,10 +0,0 @@
|
||||
"""saml source settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"saml_source_cleanup": {
|
||||
"task": "authentik.sources.saml.tasks.clean_temporary_users",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@ from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, User
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -15,8 +15,6 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||
"""Delete temporary user if the `delete_on_logout` flag is enabled"""
|
||||
if not user:
|
||||
return
|
||||
if "saml" in user.attributes:
|
||||
if "delete_on_logout" in user.attributes["saml"]:
|
||||
if user.attributes["saml"]["delete_on_logout"]:
|
||||
LOGGER.debug("Deleted temporary user", user=user)
|
||||
user.delete()
|
||||
if user.attributes.get(USER_ATTRIBUTE_DELETE_ON_LOGOUT, False):
|
||||
LOGGER.debug("Deleted temporary user", user=user)
|
||||
user.delete()
|
||||
|
@ -1,42 +0,0 @@
|
||||
"""authentik saml source tasks"""
|
||||
from django.utils.timezone import now
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.events.monitored_tasks import (
|
||||
MonitoredTask,
|
||||
TaskResult,
|
||||
TaskResultStatus,
|
||||
prefill_task,
|
||||
)
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
@prefill_task
|
||||
def clean_temporary_users(self: MonitoredTask):
|
||||
"""Remove temporary users created by SAML Sources"""
|
||||
_now = now()
|
||||
messages = []
|
||||
deleted_users = 0
|
||||
for user in User.objects.filter(attributes__saml__isnull=False):
|
||||
sources = SAMLSource.objects.filter(pk=user.attributes.get("saml", {}).get("source", ""))
|
||||
if not sources.exists():
|
||||
LOGGER.warning("User has an invalid SAML Source and won't be deleted!", user=user)
|
||||
messages.append(f"User {user} has an invalid SAML Source and won't be deleted!")
|
||||
continue
|
||||
source = sources.first()
|
||||
source_delta = timedelta_from_string(source.temporary_user_delete_after)
|
||||
if (
|
||||
_now - user.last_login >= source_delta
|
||||
and not AuthenticatedSession.objects.filter(user=user).exists()
|
||||
):
|
||||
LOGGER.debug("User is expired and will be deleted.", user=user, delta=source_delta)
|
||||
user.delete()
|
||||
deleted_users += 1
|
||||
messages.append(f"Successfully deleted {deleted_users} users.")
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
|
@ -68,6 +68,8 @@ class AuthenticatorDuoStageViewSet(UsedByMixin, ModelViewSet):
|
||||
client = stage.client
|
||||
user_id = self.request.session.get(SESSION_KEY_DUO_USER_ID)
|
||||
activation_code = self.request.session.get(SESSION_KEY_DUO_ACTIVATION_CODE)
|
||||
if not user_id or not activation_code:
|
||||
return Response(status=420)
|
||||
status = client.enroll_status(user_id, activation_code)
|
||||
if status == "success":
|
||||
return Response(status=204)
|
||||
@ -95,18 +97,20 @@ class AuthenticatorDuoStageViewSet(UsedByMixin, ModelViewSet):
|
||||
def import_devices(self, request: Request, pk: str) -> Response:
|
||||
"""Import duo devices into authentik"""
|
||||
stage: AuthenticatorDuoStage = self.get_object()
|
||||
users = get_objects_for_user(request.user, "authentik_core.view_user").filter(
|
||||
username=request.query_params.get("username", "")
|
||||
user = (
|
||||
get_objects_for_user(request.user, "authentik_core.view_user")
|
||||
.filter(username=request.query_params.get("username", ""))
|
||||
.first()
|
||||
)
|
||||
if not users.exists():
|
||||
if not user:
|
||||
return Response(data={"non_field_errors": ["user does not exist"]}, status=400)
|
||||
devices = DuoDevice.objects.filter(
|
||||
duo_user_id=request.query_params.get("duo_user_id"), user=users.first(), stage=stage
|
||||
)
|
||||
if devices.exists():
|
||||
device = DuoDevice.objects.filter(
|
||||
duo_user_id=request.query_params.get("duo_user_id"), user=user, stage=stage
|
||||
).first()
|
||||
if device:
|
||||
return Response(data={"non_field_errors": ["device exists already"]}, status=400)
|
||||
DuoDevice.objects.create(
|
||||
duo_user_id=request.query_params.get("duo_user_id"), user=users.first(), stage=stage
|
||||
duo_user_id=request.query_params.get("duo_user_id"), user=user, stage=stage
|
||||
)
|
||||
return Response(status=204)
|
||||
|
||||
|
@ -23,6 +23,7 @@ from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
LOGGER = get_logger()
|
||||
PLAN_CONTEXT_EMAIL_SENT = "email_sent"
|
||||
PLAN_CONTEXT_EMAIL_OVERRIDE = "email"
|
||||
|
||||
|
||||
class EmailChallenge(Challenge):
|
||||
@ -83,13 +84,16 @@ class EmailStageView(ChallengeStageView):
|
||||
"""Helper function that sends the actual email. Implies that you've
|
||||
already checked that there is a pending user."""
|
||||
pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||
email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None)
|
||||
if not email:
|
||||
email = pending_user.email
|
||||
current_stage: EmailStage = self.executor.current_stage
|
||||
token = self.get_token()
|
||||
# Send mail to user
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(current_stage.subject),
|
||||
template_name=current_stage.template,
|
||||
to=[pending_user.email],
|
||||
to=[email],
|
||||
template_context={
|
||||
"url": self.get_full_url(**{QS_KEY_TOKEN: token.key}),
|
||||
"user": pending_user,
|
||||
|
@ -14,7 +14,7 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.stage import QS_KEY_TOKEN
|
||||
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, QS_KEY_TOKEN
|
||||
|
||||
|
||||
class TestEmailStage(FlowTestCase):
|
||||
@ -75,6 +75,27 @@ class TestEmailStage(FlowTestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].subject, "authentik")
|
||||
self.assertEqual(mail.outbox[0].to, ["test@beryju.org"])
|
||||
|
||||
def test_pending_user_override(self):
|
||||
"""Test with pending user (override to)"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
plan.context[PLAN_CONTEXT_EMAIL_OVERRIDE] = "foo@bar.baz"
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
with patch(
|
||||
"authentik.stages.email.models.EmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
):
|
||||
response = self.client.post(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].subject, "authentik")
|
||||
self.assertEqual(mail.outbox[0].to, ["foo@bar.baz"])
|
||||
|
||||
def test_use_global_settings(self):
|
||||
"""Test use_global_settings"""
|
||||
|
@ -54,6 +54,7 @@ class InvitationSerializer(ModelSerializer):
|
||||
model = Invitation
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"expires",
|
||||
"fixed_data",
|
||||
"created_by",
|
||||
@ -67,8 +68,8 @@ class InvitationViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = Invitation.objects.all()
|
||||
serializer_class = InvitationSerializer
|
||||
ordering = ["-expires"]
|
||||
search_fields = ["created_by__username", "expires"]
|
||||
filterset_fields = ["created_by__username", "expires"]
|
||||
search_fields = ["name", "created_by__username", "expires"]
|
||||
filterset_fields = ["name", "created_by__username", "expires"]
|
||||
|
||||
def perform_create(self, serializer: InvitationSerializer):
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
@ -0,0 +1,122 @@
|
||||
# Generated by Django 4.0.3 on 2022-03-26 17:24
|
||||
|
||||
import uuid
|
||||
|
||||
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 migrate_add_name(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
Invitation = apps.get_model("authentik_stages_invitation", "invitation")
|
||||
|
||||
for invite in Invitation.objects.using(db_alias).all():
|
||||
invite.name = invite.pk.hex
|
||||
invite.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("authentik_stages_invitation", "0001_initial"),
|
||||
("authentik_stages_invitation", "0002_auto_20201225_2143"),
|
||||
("authentik_stages_invitation", "0003_auto_20201227_1210"),
|
||||
("authentik_stages_invitation", "0004_invitation_single_use"),
|
||||
("authentik_stages_invitation", "0005_auto_20210901_1211"),
|
||||
("authentik_stages_invitation", "0006_invitation_name"),
|
||||
]
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("authentik_flows", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="InvitationStage",
|
||||
fields=[
|
||||
(
|
||||
"stage_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_flows.stage",
|
||||
),
|
||||
),
|
||||
(
|
||||
"continue_flow_without_invitation",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If this flag is set, this Stage will jump to the next Stage when no Invitation is given. By default this Stage will cancel the Flow when no invitation is given.",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Invitation Stage",
|
||||
"verbose_name_plural": "Invitation Stages",
|
||||
},
|
||||
bases=("authentik_flows.stage",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Invitation",
|
||||
fields=[
|
||||
(
|
||||
"invite_uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"expires",
|
||||
models.DateTimeField(default=authentik.core.models.default_token_duration),
|
||||
),
|
||||
(
|
||||
"fixed_data",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Optional fixed data to enforce on user enrollment.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
(
|
||||
"single_use",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="When enabled, the invitation will be deleted after usage.",
|
||||
),
|
||||
),
|
||||
("expiring", models.BooleanField(default=True)),
|
||||
("name", models.SlugField(default="")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Invitation",
|
||||
"verbose_name_plural": "Invitations",
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=migrate_add_name,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="invitation",
|
||||
name="name",
|
||||
field=models.SlugField(),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
@ -0,0 +1,38 @@
|
||||
# Generated by Django 4.0.3 on 2022-03-26 17:22
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_add_name(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
Invitation = apps.get_model("authentik_stages_invitation", "invitation")
|
||||
|
||||
for invite in Invitation.objects.using(db_alias).all():
|
||||
invite.name = invite.pk.hex
|
||||
invite.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_invitation", "0005_auto_20210901_1211"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="invitation",
|
||||
name="name",
|
||||
field=models.SlugField(default=""),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(migrate_add_name),
|
||||
migrations.AlterField(
|
||||
model_name="invitation",
|
||||
name="name",
|
||||
field=models.SlugField(),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
@ -52,6 +52,8 @@ class Invitation(ExpiringModel):
|
||||
|
||||
invite_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
name = models.SlugField()
|
||||
|
||||
single_use = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("When enabled, the invitation will be deleted after usage."),
|
||||
|
@ -145,7 +145,7 @@ class TestInvitationsAPI(APITestCase):
|
||||
"""Test Invitations creation endpoint"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:invitation-list"),
|
||||
{"identifier": "test-token", "fixed_data": {}},
|
||||
{"name": "test-token", "fixed_data": {}},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
@ -119,10 +119,12 @@ class Prompt(SerializerModel):
|
||||
}
|
||||
if self.type == FieldTypes.TEXT:
|
||||
kwargs["trim_whitespace"] = False
|
||||
kwargs["allow_blank"] = not self.required
|
||||
if self.type == FieldTypes.TEXT_READ_ONLY:
|
||||
field_class = ReadOnlyField
|
||||
if self.type == FieldTypes.EMAIL:
|
||||
field_class = EmailField
|
||||
kwargs["allow_blank"] = not self.required
|
||||
if self.type == FieldTypes.NUMBER:
|
||||
field_class = IntegerField
|
||||
if self.type == FieldTypes.CHECKBOX:
|
||||
|
@ -8,7 +8,7 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.fields import BooleanField, CharField, ChoiceField, IntegerField
|
||||
from rest_framework.fields import BooleanField, CharField, ChoiceField, IntegerField, empty
|
||||
from rest_framework.serializers import ValidationError
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@ -55,6 +55,7 @@ class PromptChallengeResponse(ChallengeResponse):
|
||||
stage: PromptStage = kwargs.pop("stage", None)
|
||||
plan: FlowPlan = kwargs.pop("plan", None)
|
||||
request: HttpRequest = kwargs.pop("request", None)
|
||||
user: User = kwargs.pop("user", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.stage = stage
|
||||
self.plan = plan
|
||||
@ -65,7 +66,9 @@ class PromptChallengeResponse(ChallengeResponse):
|
||||
fields = list(self.stage.fields.all())
|
||||
for field in fields:
|
||||
field: Prompt
|
||||
current = plan.context.get(PLAN_CONTEXT_PROMPT, {}).get(field.field_key)
|
||||
current = field.get_placeholder(
|
||||
plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request
|
||||
)
|
||||
self.fields[field.field_key] = field.field(current)
|
||||
# Special handling for fields with username type
|
||||
# these check for existing users with the same username
|
||||
@ -101,7 +104,11 @@ class PromptChallengeResponse(ChallengeResponse):
|
||||
)
|
||||
for static_hidden in static_hidden_fields:
|
||||
field = self.fields[static_hidden.field_key]
|
||||
attrs[static_hidden.field_key] = field.default
|
||||
default = field.default
|
||||
# Prevent rest_framework.fields.empty from ending up in policies and events
|
||||
if default == empty:
|
||||
default = ""
|
||||
attrs[static_hidden.field_key] = default
|
||||
|
||||
# Check if we have two password fields, and make sure they are the same
|
||||
password_fields: QuerySet[Prompt] = self.stage.fields.filter(type=FieldTypes.PASSWORD)
|
||||
@ -112,7 +119,6 @@ class PromptChallengeResponse(ChallengeResponse):
|
||||
engine = ListPolicyEngine(self.stage.validation_policies.all(), user, self.request)
|
||||
engine.mode = PolicyEngineMode.MODE_ALL
|
||||
engine.request.context[PLAN_CONTEXT_PROMPT] = attrs
|
||||
engine.request.context.update(attrs)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if not result.passing:
|
||||
@ -191,6 +197,7 @@ class PromptStageView(ChallengeStageView):
|
||||
request=self.request,
|
||||
stage=self.executor.current_stage,
|
||||
plan=self.executor.plan,
|
||||
user=self.get_pending_user(),
|
||||
)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
|
@ -129,7 +129,10 @@ class TestPromptStage(FlowTestCase):
|
||||
def test_valid_challenge_with_policy(self) -> PromptChallengeResponse:
|
||||
"""Test challenge_response validation"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
expr = "return request.context['password_prompt'] == request.context['password2_prompt']"
|
||||
expr = (
|
||||
"return request.context['prompt_data']['password_prompt'] "
|
||||
"== request.context['prompt_data']['password2_prompt']"
|
||||
)
|
||||
expr_policy = ExpressionPolicy.objects.create(name="validate-form", expression=expr)
|
||||
self.stage.validation_policies.set([expr_policy])
|
||||
self.stage.save()
|
||||
@ -274,9 +277,6 @@ class TestPromptStage(FlowTestCase):
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), prompt.placeholder
|
||||
)
|
||||
|
||||
def test_field_types(self):
|
||||
"""Ensure all field types can successfully be created"""
|
||||
|
||||
def test_invalid_save(self):
|
||||
"""Ensure field can't be saved with invalid type"""
|
||||
prompt: Prompt = Prompt(
|
||||
@ -292,7 +292,7 @@ class TestPromptStage(FlowTestCase):
|
||||
prompt.save()
|
||||
|
||||
|
||||
def field_type_tester_factory(field_type: FieldTypes):
|
||||
def field_type_tester_factory(field_type: FieldTypes, required: bool):
|
||||
"""Test field for field_type"""
|
||||
|
||||
def tester(self: TestPromptStage):
|
||||
@ -304,11 +304,16 @@ def field_type_tester_factory(field_type: FieldTypes):
|
||||
placeholder_expression=False,
|
||||
sub_text="test",
|
||||
order=123,
|
||||
required=required,
|
||||
)
|
||||
self.assertIsNotNone(prompt.field("foo"))
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
for _type in FieldTypes:
|
||||
setattr(TestPromptStage, f"test_field_type_{_type}", field_type_tester_factory(_type))
|
||||
for _required in (True, False):
|
||||
for _type in FieldTypes:
|
||||
test_name = f"test_field_type_{_type}"
|
||||
if _required:
|
||||
test_name += "_required"
|
||||
setattr(TestPromptStage, test_name, field_type_tester_factory(_type, _required))
|
||||
|
@ -98,7 +98,6 @@ class UserWriteStageView(StageView):
|
||||
LOGGER.debug("discarding key", key=key)
|
||||
continue
|
||||
UserWriteStageView.write_attribute(user, key, value)
|
||||
print(user.attributes)
|
||||
# Extra check to prevent flows from saving a user with a blank username
|
||||
if user.username == "":
|
||||
LOGGER.warning("Aborting write to empty username", user=user)
|
||||
|
@ -53,6 +53,7 @@ class TenantSerializer(ModelSerializer):
|
||||
"flow_user_settings",
|
||||
"event_retention",
|
||||
"web_certificate",
|
||||
"attributes",
|
||||
]
|
||||
|
||||
|
||||
@ -86,7 +87,21 @@ class TenantViewSet(UsedByMixin, ModelViewSet):
|
||||
"branding_title",
|
||||
"web_certificate__name",
|
||||
]
|
||||
filterset_fields = "__all__"
|
||||
filterset_fields = [
|
||||
"tenant_uuid",
|
||||
"domain",
|
||||
"default",
|
||||
"branding_title",
|
||||
"branding_logo",
|
||||
"branding_favicon",
|
||||
"flow_authentication",
|
||||
"flow_invalidation",
|
||||
"flow_recovery",
|
||||
"flow_unenrollment",
|
||||
"flow_user_settings",
|
||||
"event_retention",
|
||||
"web_certificate",
|
||||
]
|
||||
ordering = ["domain"]
|
||||
|
||||
@extend_schema(
|
||||
|
@ -17,21 +17,21 @@ from authentik.core.models import (
|
||||
)
|
||||
prompt_data = request.context.get("prompt_data")
|
||||
|
||||
if not request.user.group_attributes().get(
|
||||
if not request.user.group_attributes(request.http_request).get(
|
||||
USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True)
|
||||
):
|
||||
if prompt_data.get("email") != request.user.email:
|
||||
ak_message("Not allowed to change email address.")
|
||||
return False
|
||||
|
||||
if not request.user.group_attributes().get(
|
||||
if not request.user.group_attributes(request.http_request).get(
|
||||
USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True)
|
||||
):
|
||||
if prompt_data.get("name") != request.user.name:
|
||||
ak_message("Not allowed to change name.")
|
||||
return False
|
||||
|
||||
if not request.user.group_attributes().get(
|
||||
if not request.user.group_attributes(request.http_request).get(
|
||||
USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True)
|
||||
):
|
||||
if prompt_data.get("username") != request.user.username:
|
||||
|
18
authentik/tenants/migrations/0003_tenant_attributes.py
Normal file
18
authentik/tenants/migrations/0003_tenant_attributes.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.0.3 on 2022-04-06 08:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_tenants", "0002_tenant_flow_user_settings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tenant",
|
||||
name="attributes",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
@ -63,6 +63,8 @@ class Tenant(models.Model):
|
||||
help_text=_(("Web Certificate used by the authentik Core webserver.")),
|
||||
)
|
||||
|
||||
attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.default:
|
||||
return "Default tenant"
|
||||
|
@ -27,6 +27,7 @@ func main() {
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
go debug.EnableDebugServer()
|
||||
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
|
||||
|
@ -32,6 +32,7 @@ func main() {
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
go debug.EnableDebugServer()
|
||||
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
|
||||
|
@ -28,6 +28,7 @@ func main() {
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
go debug.EnableDebugServer()
|
||||
l := log.WithField("logger", "authentik.root")
|
||||
|
@ -17,7 +17,7 @@ services:
|
||||
image: redis:alpine
|
||||
restart: unless-stopped
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.3.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.4.1}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -38,7 +38,7 @@ services:
|
||||
- "0.0.0.0:${AUTHENTIK_PORT_HTTP:-9000}:9000"
|
||||
- "0.0.0.0:${AUTHENTIK_PORT_HTTPS:-9443}:9443"
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.3.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.4.1}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
4
go.mod
4
go.mod
@ -7,7 +7,7 @@ require (
|
||||
github.com/coreos/go-oidc v2.2.1+incompatible
|
||||
github.com/garyburd/redigo v1.6.2 // indirect
|
||||
github.com/getsentry/sentry-go v0.13.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.3
|
||||
github.com/go-openapi/runtime v0.23.3
|
||||
github.com/go-openapi/strfmt v0.21.2
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
@ -27,7 +27,7 @@ require (
|
||||
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/stretchr/testify v1.7.1
|
||||
goauthentik.io/api/v3 v3.2022032.1
|
||||
goauthentik.io/api/v3 v3.2022033.11
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
|
21
go.sum
21
go.sum
@ -32,8 +32,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e h1:ZU22z/2YRFLyf/P4ZwUYSdNCWsMEI0VeyrFoI2rAhJQ=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||
@ -102,8 +102,8 @@ github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NW
|
||||
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
@ -113,8 +113,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.2 h1:zFZKcXKLqZpFMrMQGHeHWKXbDTdNCmhGY9AK41zPh+8=
|
||||
github.com/go-ldap/ldap/v3 v3.4.2/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg=
|
||||
github.com/go-ldap/ldap/v3 v3.4.3 h1:JCKUtJPIcyOuG7ctGabLKMgIlKnGumD/iGjuWeEruDI=
|
||||
github.com/go-ldap/ldap/v3 v3.4.3/go.mod h1:7LdHfVt6iIOESVEe3Bs4Jp2sHEKgDeduAhgM1/f9qmo=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
@ -461,8 +461,8 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
goauthentik.io/api/v3 v3.2022032.1 h1:8PCy0tHUNjGVNJ7nuKPsK64vURFTeqUifyj68RlqMK4=
|
||||
goauthentik.io/api/v3 v3.2022032.1/go.mod h1:QM9J32HgYE4gL71lWAfAoXSPdSmLVLW08itfLI3Mo10=
|
||||
goauthentik.io/api/v3 v3.2022033.11 h1:BCE1LgppO135/qAY607EukQBI4Bbta8N23FYUvpSPws=
|
||||
goauthentik.io/api/v3 v3.2022033.11/go.mod h1:QM9J32HgYE4gL71lWAfAoXSPdSmLVLW08itfLI3Mo10=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@ -473,12 +473,12 @@ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
|
||||
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@ -545,6 +545,7 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
|
@ -86,6 +86,6 @@ func ConfigureLogger() {
|
||||
if G.Debug {
|
||||
log.SetFormatter(&log.TextFormatter{FieldMap: fm})
|
||||
} else {
|
||||
log.SetFormatter(&log.JSONFormatter{FieldMap: fm})
|
||||
log.SetFormatter(&log.JSONFormatter{FieldMap: fm, DisableHTMLEscape: true})
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ func FullVersion() string {
|
||||
}
|
||||
|
||||
func OutpostUserAgent() string {
|
||||
return fmt.Sprintf("authentik-outpost@%s", FullVersion())
|
||||
return fmt.Sprintf("goauthentik.io/outpost/%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2022.3.3"
|
||||
const VERSION = "2022.4.1"
|
||||
|
@ -70,7 +70,7 @@ func (g *GoUnicorn) Start() error {
|
||||
func (g *GoUnicorn) healthcheck() {
|
||||
g.log.Debug("starting healthcheck")
|
||||
h := &http.Client{
|
||||
Transport: ak.NewUserAgentTransport("goauthentik.io go proxy healthcheck", http.DefaultTransport),
|
||||
Transport: ak.NewUserAgentTransport("goauthentik.io/proxy/healthcheck", http.DefaultTransport),
|
||||
}
|
||||
check := func() bool {
|
||||
res, err := h.Get("http://localhost:8000/-/health/live/")
|
||||
|
@ -16,8 +16,12 @@ import (
|
||||
func doGlobalSetup(outpost api.Outpost, globalConfig api.Config) {
|
||||
l := log.WithField("logger", "authentik.outpost")
|
||||
m := outpost.Managed.Get()
|
||||
level, ok := outpost.Config[ConfigLogLevel]
|
||||
if !ok {
|
||||
level = "info"
|
||||
}
|
||||
if m == nil || *m == "" {
|
||||
switch outpost.Config[ConfigLogLevel].(string) {
|
||||
switch level.(string) {
|
||||
case "trace":
|
||||
log.SetLevel(log.TraceLevel)
|
||||
case "debug":
|
||||
|
@ -140,7 +140,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
|
||||
for _, u := range g.UsersObj {
|
||||
if flags.UserPk == u.Pk {
|
||||
//TODO: Is there a better way to clone this object?
|
||||
fg := api.NewGroup(g.Pk, g.Name, g.Parent, g.ParentName, []int32{flags.UserPk}, []api.GroupMember{u})
|
||||
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.Parent, g.ParentName, []int32{flags.UserPk}, []api.GroupMember{u})
|
||||
fg.SetAttributes(*g.Attributes)
|
||||
fg.SetIsSuperuser(*g.IsSuperuser)
|
||||
groups = append(groups, group.FromAPIGroup(*fg, ms.si))
|
||||
|
@ -2,9 +2,7 @@ package ldap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"goauthentik.io/api/v3"
|
||||
)
|
||||
@ -54,20 +52,5 @@ func (pi *ProviderInstance) GetGidNumber(group api.Group) string {
|
||||
return gidNumber
|
||||
}
|
||||
|
||||
return strconv.FormatInt(int64(pi.gidStartNumber+pi.GetRIDForGroup(group.Pk)), 10)
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) GetRIDForGroup(uid string) int32 {
|
||||
var i big.Int
|
||||
i.SetString(strings.Replace(uid, "-", "", -1), 16)
|
||||
intStr := i.String()
|
||||
|
||||
// Get the last 5 characters/digits of the int-version of the UUID
|
||||
gid, err := strconv.Atoi(intStr[len(intStr)-5:])
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return int32(gid)
|
||||
return strconv.FormatInt(int64(pi.gidStartNumber+group.NumPk), 10)
|
||||
}
|
||||
|
@ -29,7 +29,9 @@ func ldapResolveTypeSingle(in interface{}) *string {
|
||||
s := BoolToString(*t)
|
||||
return &s
|
||||
default:
|
||||
log.WithField("type", reflect.TypeOf(in).String()).Warning("Type can't be mapped to LDAP yet")
|
||||
if in != nil {
|
||||
log.WithField("type", reflect.TypeOf(in).String()).Warning("Type can't be mapped to LDAP yet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,11 @@ import (
|
||||
"goauthentik.io/api/v3"
|
||||
)
|
||||
|
||||
func Test_ldapResolveTypeSingle_nil(t *testing.T) {
|
||||
var ex *string
|
||||
assert.Equal(t, ex, ldapResolveTypeSingle(nil))
|
||||
}
|
||||
|
||||
func TestAKAttrsToLDAP_String(t *testing.T) {
|
||||
var d *map[string]interface{}
|
||||
|
||||
@ -54,7 +59,7 @@ func TestAKAttrsToLDAP_Dict(t *testing.T) {
|
||||
assert.Equal(t, 1, len(AKAttrsToLDAP(d)))
|
||||
assert.Equal(t, "foo", AKAttrsToLDAP(d)[0].Name)
|
||||
// Dicts are currently unsupported, but make sure we don't crash
|
||||
// assert.Equal(t, []string{nil}, AKAttrsToLDAP(d)[0].Values)
|
||||
assert.Equal(t, []string([]string(nil)), AKAttrsToLDAP(d)[0].Values)
|
||||
}
|
||||
|
||||
func TestAKAttrsToLDAP_Mixed(t *testing.T) {
|
||||
@ -68,5 +73,5 @@ func TestAKAttrsToLDAP_Mixed(t *testing.T) {
|
||||
assert.Equal(t, 1, len(AKAttrsToLDAP(d)))
|
||||
assert.Equal(t, "foo", AKAttrsToLDAP(d)[0].Name)
|
||||
// Dicts are currently unsupported, but make sure we don't crash
|
||||
// assert.Equal(t, []string{nil}, AKAttrsToLDAP(d)[0].Values)
|
||||
assert.Equal(t, []string{"foo", ""}, AKAttrsToLDAP(d)[0].Values)
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ from json import dumps
|
||||
from sys import exit as sysexit
|
||||
from sys import stderr
|
||||
from time import sleep, time
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from psycopg2 import OperationalError, connect
|
||||
from redis import Redis
|
||||
@ -58,7 +59,7 @@ if CONFIG.y_bool("redis.tls", False):
|
||||
REDIS_PROTOCOL_PREFIX = "rediss://"
|
||||
REDIS_URL = (
|
||||
f"{REDIS_PROTOCOL_PREFIX}:"
|
||||
f"{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:"
|
||||
f"{quote_plus(CONFIG.y('redis.password'))}@{quote_plus(CONFIG.y('redis.host'))}:"
|
||||
f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.ws_db')}"
|
||||
)
|
||||
while True:
|
||||
|
Binary file not shown.
@ -2,7 +2,7 @@
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#
|
||||
# Translators:
|
||||
# Vri, 2021
|
||||
# Lars Lehmann <lars@lars-lehmann.net>, 2021
|
||||
@ -11,7 +11,7 @@
|
||||
# Rhea Alleen, 2021
|
||||
# David <david@techniknews.net>, 2021
|
||||
# Steve Oswald, 2022
|
||||
#
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
@ -734,7 +734,7 @@ msgid "RS256 (Asymmetric Encryption)"
|
||||
msgstr "RS256 (Asymmetrische Verschlüsselung)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py:93
|
||||
msgid "EC256 (Asymmetric Encryption)"
|
||||
msgid "ES256 (Asymmetric Encryption)"
|
||||
msgstr "RS256 (Asymmetrische Verschlüsselung)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py:99
|
||||
|
@ -678,7 +678,7 @@ msgid "RS256 (Asymmetric Encryption)"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/models.py:93
|
||||
msgid "EC256 (Asymmetric Encryption)"
|
||||
msgid "ES256 (Asymmetric Encryption)"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/models.py:99
|
||||
|
@ -2,10 +2,10 @@
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#
|
||||
# Translators:
|
||||
# jcamat, 2022
|
||||
#
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
@ -726,8 +726,8 @@ msgid "RS256 (Asymmetric Encryption)"
|
||||
msgstr "RS256 (cifrado asimétrico)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py:93
|
||||
msgid "EC256 (Asymmetric Encryption)"
|
||||
msgstr "EC256 (cifrado asimétrico)"
|
||||
msgid "ES256 (Asymmetric Encryption)"
|
||||
msgstr "ES256 (cifrado asimétrico)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py:99
|
||||
msgid "Scope used by the client"
|
||||
|
@ -2,10 +2,10 @@
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#
|
||||
# Translators:
|
||||
# Oktay Altunergil, 2022
|
||||
#
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
@ -719,8 +719,8 @@ msgid "RS256 (Asymmetric Encryption)"
|
||||
msgstr "RS256 (Asimetrik Şifreleme)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py:93
|
||||
msgid "EC256 (Asymmetric Encryption)"
|
||||
msgstr "EC256 (Asimetrik Şifreleme)"
|
||||
msgid "ES256 (Asymmetric Encryption)"
|
||||
msgstr "ES256 (Asimetrik Şifreleme)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py:99
|
||||
msgid "Scope used by the client"
|
||||
|
Binary file not shown.
@ -2,11 +2,11 @@
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#
|
||||
# Translators:
|
||||
# Chen Zhikai, 2022
|
||||
# 刘松, 2022
|
||||
#
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
@ -696,8 +696,8 @@ msgid "RS256 (Asymmetric Encryption)"
|
||||
msgstr "RS256(非对称加密)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py:93
|
||||
msgid "EC256 (Asymmetric Encryption)"
|
||||
msgstr "EC256(非对称加密)"
|
||||
msgid "ES256 (Asymmetric Encryption)"
|
||||
msgstr "ES256(非对称加密)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py:99
|
||||
msgid "Scope used by the client"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user