Compare commits
1 Commits
version/20
...
root/confi
Author | SHA1 | Date | |
---|---|---|---|
3fa987f443 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2023.2.3
|
||||
current_version = 2023.1.2
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||
|
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@ -18,7 +18,7 @@ runs:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3.1.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Setup dependencies
|
||||
|
7
.github/workflows/ci-main.yml
vendored
7
.github/workflows/ci-main.yml
vendored
@ -80,7 +80,6 @@ jobs:
|
||||
run: poetry run python -m lifecycle.migrate
|
||||
test-unittest:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup authentik env
|
||||
@ -95,7 +94,6 @@ jobs:
|
||||
flags: unit
|
||||
test-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup authentik env
|
||||
@ -113,7 +111,6 @@ jobs:
|
||||
test-e2e:
|
||||
name: test-e2e (${{ matrix.job.name }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -191,7 +188,7 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
secrets: |
|
||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||
@ -232,7 +229,7 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
secrets: |
|
||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||
|
9
.github/workflows/ci-outpost.yml
vendored
9
.github/workflows/ci-outpost.yml
vendored
@ -49,7 +49,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo mark
|
||||
build-container:
|
||||
build:
|
||||
timeout-minutes: 120
|
||||
needs:
|
||||
- ci-outpost-mark
|
||||
@ -83,7 +83,7 @@ jobs:
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
tags: |
|
||||
@ -94,8 +94,7 @@ jobs:
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||
platforms: ${{ matrix.arch }}
|
||||
context: .
|
||||
build-binary:
|
||||
build-outpost-binary:
|
||||
timeout-minutes: 120
|
||||
needs:
|
||||
- ci-outpost-mark
|
||||
@ -115,7 +114,7 @@ jobs:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Generate API
|
||||
|
10
.github/workflows/ci-web.yml
vendored
10
.github/workflows/ci-web.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
@ -49,7 +49,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
@ -65,7 +65,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
@ -97,7 +97,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
|
17
.github/workflows/ci-website.yml
vendored
17
.github/workflows/ci-website.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
@ -25,24 +25,9 @@ jobs:
|
||||
- name: prettier
|
||||
working-directory: website/
|
||||
run: npm run prettier-check
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
run: npm ci
|
||||
- name: test
|
||||
working-directory: website/
|
||||
run: npm test
|
||||
ci-website-mark:
|
||||
needs:
|
||||
- lint-prettier
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo mark
|
||||
|
6
.github/workflows/release-publish.yml
vendored
6
.github/workflows/release-publish.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
secrets: |
|
||||
@ -76,7 +76,7 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
@ -108,7 +108,7 @@ jobs:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build web
|
||||
|
2
.github/workflows/web-api-publish.yml
vendored
2
.github/workflows/web-api-publish.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '16'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Generate API Client
|
||||
run: make gen-client-ts
|
||||
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -46,6 +46,5 @@
|
||||
"url": "https://github.com/goauthentik/authentik/issues/<num>",
|
||||
"ignoreCase": false
|
||||
}
|
||||
],
|
||||
"go.testFlags": ["-count=1"]
|
||||
]
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ WORKDIR /work/web
|
||||
RUN npm ci && npm run build
|
||||
|
||||
# Stage 3: Poetry to requirements.txt export
|
||||
FROM docker.io/python:3.11.2-slim-bullseye AS poetry-locker
|
||||
FROM docker.io/python:3.11.1-slim-bullseye AS poetry-locker
|
||||
|
||||
WORKDIR /work
|
||||
COPY ./pyproject.toml /work
|
||||
@ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \
|
||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||
|
||||
# Stage 4: Build go proxy
|
||||
FROM docker.io/golang:1.20.1-bullseye AS go-builder
|
||||
FROM docker.io/golang:1.19.5-bullseye AS go-builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
@ -62,7 +62,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
"
|
||||
|
||||
# Stage 6: Run
|
||||
FROM docker.io/python:3.11.2-slim-bullseye AS final-image
|
||||
FROM docker.io/python:3.11.1-slim-bullseye AS final-image
|
||||
|
||||
LABEL org.opencontainers.image.url https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
|
||||
|
12
README.md
12
README.md
@ -38,10 +38,6 @@ See [Development Documentation](https://goauthentik.io/developer-docs/?utm_sourc
|
||||
|
||||
See [SECURITY.md](SECURITY.md)
|
||||
|
||||
## Support
|
||||
|
||||
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR!
|
||||
|
||||
## Sponsors
|
||||
|
||||
This project is proudly sponsored by:
|
||||
@ -53,3 +49,11 @@ This project is proudly sponsored by:
|
||||
</p>
|
||||
|
||||
DigitalOcean provides development and testing resources for authentik.
|
||||
|
||||
<p>
|
||||
<a href="https://www.netlify.com">
|
||||
<img src="https://www.netlify.com/img/global/badges/netlify-color-accent.svg" alt="Deploys by Netlify" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Netlify hosts the [goauthentik.io](https://goauthentik.io) site.
|
||||
|
@ -2,7 +2,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2023.2.3"
|
||||
__version__ = "2023.1.2"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -97,14 +97,8 @@ class SystemView(APIView):
|
||||
permission_classes = [IsAdminUser]
|
||||
pagination_class = None
|
||||
filter_backends = []
|
||||
serializer_class = SystemSerializer
|
||||
|
||||
@extend_schema(responses={200: SystemSerializer(many=False)})
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get system information."""
|
||||
return Response(SystemSerializer(request).data)
|
||||
|
||||
@extend_schema(responses={200: SystemSerializer(many=False)})
|
||||
def post(self, request: Request) -> Response:
|
||||
"""Get system information."""
|
||||
return Response(SystemSerializer(request).data)
|
||||
|
@ -50,8 +50,7 @@ class TaskSerializer(PassiveSerializer):
|
||||
are pickled in cache. In that case, just delete the info"""
|
||||
try:
|
||||
return super().to_representation(instance)
|
||||
# pylint: disable=broad-except
|
||||
except Exception: # pragma: no cover
|
||||
except AttributeError: # pragma: no cover
|
||||
if isinstance(self.instance, list):
|
||||
for inst in self.instance:
|
||||
inst.delete()
|
||||
|
@ -18,4 +18,4 @@ def monitoring_set_workers(sender, **kwargs):
|
||||
def monitoring_set_tasks(sender, **kwargs):
|
||||
"""Set task gauges"""
|
||||
for task in TaskInfo.all().values():
|
||||
task.update_metrics()
|
||||
task.set_prom_metrics()
|
||||
|
@ -42,7 +42,7 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
|
||||
|
||||
def auth_user_lookup(raw_header: bytes) -> Optional[User]:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
from authentik.providers.oauth2.models import AccessToken
|
||||
from authentik.providers.oauth2.models import RefreshToken
|
||||
|
||||
auth_credentials = validate_auth(raw_header)
|
||||
if not auth_credentials:
|
||||
@ -55,8 +55,8 @@ def auth_user_lookup(raw_header: bytes) -> Optional[User]:
|
||||
CTX_AUTH_VIA.set("api_token")
|
||||
return key_token.user
|
||||
# then try to auth via JWT
|
||||
jwt_token = AccessToken.filter_not_expired(
|
||||
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||
jwt_token = RefreshToken.filter_not_expired(
|
||||
refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||
).first()
|
||||
if jwt_token:
|
||||
# Double-check scopes, since they are saved in a single string
|
||||
|
@ -1,5 +1,4 @@
|
||||
"""Test API Authentication"""
|
||||
import json
|
||||
from base64 import b64encode
|
||||
|
||||
from django.conf import settings
|
||||
@ -12,7 +11,7 @@ from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||
|
||||
|
||||
class TestAPIAuth(TestCase):
|
||||
@ -64,26 +63,24 @@ class TestAPIAuth(TestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
refresh = AccessToken.objects.create(
|
||||
refresh = RefreshToken.objects.create(
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
token=generate_id(),
|
||||
refresh_token=generate_id(),
|
||||
_scope=SCOPE_AUTHENTIK_API,
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||
|
||||
def test_jwt_missing_scope(self):
|
||||
"""Test valid JWT"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
refresh = AccessToken.objects.create(
|
||||
refresh = RefreshToken.objects.create(
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
token=generate_id(),
|
||||
refresh_token=generate_id(),
|
||||
_scope="",
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||
|
@ -50,11 +50,7 @@ from authentik.policies.reputation.api import ReputationPolicyViewSet, Reputatio
|
||||
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
||||
from authentik.providers.oauth2.api.providers import OAuth2ProviderViewSet
|
||||
from authentik.providers.oauth2.api.scopes import ScopeMappingViewSet
|
||||
from authentik.providers.oauth2.api.tokens import (
|
||||
AccessTokenViewSet,
|
||||
AuthorizationCodeViewSet,
|
||||
RefreshTokenViewSet,
|
||||
)
|
||||
from authentik.providers.oauth2.api.tokens import AuthorizationCodeViewSet, RefreshTokenViewSet
|
||||
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
|
||||
from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet
|
||||
from authentik.providers.saml.api.providers import SAMLProviderViewSet
|
||||
@ -166,7 +162,6 @@ router.register("providers/saml", SAMLProviderViewSet)
|
||||
|
||||
router.register("oauth2/authorization_codes", AuthorizationCodeViewSet)
|
||||
router.register("oauth2/refresh_tokens", RefreshTokenViewSet)
|
||||
router.register("oauth2/access_tokens", AccessTokenViewSet)
|
||||
|
||||
router.register("propertymappings/all", PropertyMappingViewSet)
|
||||
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
||||
|
@ -58,6 +58,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
||||
return super().validate(attrs)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = BlueprintInstance
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -71,6 +71,7 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [("authentik_flows", "0001_initial")]
|
||||
@ -85,12 +86,7 @@ class Migration(migrations.Migration):
|
||||
"managed",
|
||||
models.TextField(
|
||||
default=None,
|
||||
help_text=(
|
||||
"Objects which are managed by authentik. These objects are created and"
|
||||
" updated automatically. This is flag only indicates that an object can"
|
||||
" be overwritten by migrations. You can still modify the objects via"
|
||||
" the API, but expect changes to be overwritten in a later update."
|
||||
),
|
||||
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="Managed by authentik",
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_blueprints", "0001_initial"),
|
||||
]
|
||||
|
@ -29,15 +29,18 @@ class ManagedModel(models.Model):
|
||||
null=True,
|
||||
verbose_name=_("Managed by authentik"),
|
||||
help_text=_(
|
||||
"Objects which are managed by authentik. These objects are created and updated "
|
||||
"automatically. This is flag only indicates that an object can be overwritten by "
|
||||
"migrations. You can still modify the objects via the API, but expect changes "
|
||||
"to be overwritten in a later update."
|
||||
(
|
||||
"Objects which are managed by authentik. These objects are created and updated "
|
||||
"automatically. This is flag only indicates that an object can be overwritten by "
|
||||
"migrations. You can still modify the objects via the API, but expect changes "
|
||||
"to be overwritten in a later update."
|
||||
)
|
||||
),
|
||||
unique=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
@ -106,6 +109,7 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
return f"Blueprint Instance {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Blueprint Instance")
|
||||
verbose_name_plural = _("Blueprint Instances")
|
||||
unique_together = (
|
||||
|
@ -24,14 +24,18 @@ class TestBlueprintsV1(TransactionTestCase):
|
||||
importer = Importer('{"version": 3}')
|
||||
self.assertFalse(importer.validate()[0])
|
||||
importer = Importer(
|
||||
'{"version": 1,"entries":[{"identifiers":{},"attrs":{},'
|
||||
'"model": "authentik_core.User"}]}'
|
||||
(
|
||||
'{"version": 1,"entries":[{"identifiers":{},"attrs":{},'
|
||||
'"model": "authentik_core.User"}]}'
|
||||
)
|
||||
)
|
||||
self.assertFalse(importer.validate()[0])
|
||||
importer = Importer(
|
||||
'{"version": 1, "entries": [{"attrs": {"name": "test"}, '
|
||||
'"identifiers": {}, '
|
||||
'"model": "authentik_core.Group"}]}'
|
||||
(
|
||||
'{"version": 1, "entries": [{"attrs": {"name": "test"}, '
|
||||
'"identifiers": {}, '
|
||||
'"model": "authentik_core.Group"}]}'
|
||||
)
|
||||
)
|
||||
self.assertFalse(importer.validate()[0])
|
||||
|
||||
@ -55,9 +59,11 @@ class TestBlueprintsV1(TransactionTestCase):
|
||||
)
|
||||
|
||||
importer = Importer(
|
||||
'{"version": 1, "entries": [{"attrs": {"name": "test999", "attributes": '
|
||||
'{"key": ["updated_value"]}}, "identifiers": {"attributes": {"other_key": '
|
||||
'["other_value"]}}, "model": "authentik_core.Group"}]}'
|
||||
(
|
||||
'{"version": 1, "entries": [{"attrs": {"name": "test999", "attributes": '
|
||||
'{"key": ["updated_value"]}}, "identifiers": {"attributes": {"other_key": '
|
||||
'["other_value"]}}, "model": "authentik_core.Group"}]}'
|
||||
)
|
||||
)
|
||||
self.assertTrue(importer.validate()[0])
|
||||
self.assertTrue(importer.apply())
|
||||
|
@ -7,7 +7,6 @@ from dacite.config import Config
|
||||
from dacite.core import from_dict
|
||||
from dacite.exceptions import DaciteError
|
||||
from deepmerge import always_merger
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db import transaction
|
||||
from django.db.models import Model
|
||||
from django.db.models.query_utils import Q
|
||||
@ -182,10 +181,7 @@ class Importer:
|
||||
if not query:
|
||||
raise EntryInvalidError("No or invalid identifiers")
|
||||
|
||||
try:
|
||||
existing_models = model.objects.filter(query)
|
||||
except FieldError as exc:
|
||||
raise EntryInvalidError(f"Invalid identifier field: {exc}") from exc
|
||||
existing_models = model.objects.filter(query)
|
||||
|
||||
serializer_kwargs = {}
|
||||
model_instance = existing_models.first()
|
||||
@ -235,7 +231,8 @@ class Importer:
|
||||
raise IntegrityError
|
||||
except IntegrityError:
|
||||
return False
|
||||
self.logger.debug("Committing changes")
|
||||
else:
|
||||
self.logger.debug("Committing changes")
|
||||
return True
|
||||
|
||||
def _apply_models(self) -> bool:
|
||||
|
@ -56,4 +56,5 @@ class MetaApplyBlueprint(BaseMetaModel):
|
||||
return ApplyBlueprintMetaSerializer
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
||||
|
@ -14,6 +14,7 @@ class BaseMetaModel(Model):
|
||||
raise NotImplementedError
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
|
@ -63,6 +63,7 @@ class ApplicationSerializer(ModelSerializer):
|
||||
return app.get_launch_url(user)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Application
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -74,6 +74,7 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
||||
return GEOIP_READER.city_dict(instance.last_ip)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = AuthenticatedSession
|
||||
fields = [
|
||||
"uuid",
|
||||
|
@ -29,6 +29,7 @@ class GroupMemberSerializer(ModelSerializer):
|
||||
uid = CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = User
|
||||
fields = [
|
||||
"pk",
|
||||
@ -55,6 +56,7 @@ class GroupSerializer(ModelSerializer):
|
||||
num_pk = IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Group
|
||||
fields = [
|
||||
"pk",
|
||||
@ -112,6 +114,7 @@ class GroupFilter(FilterSet):
|
||||
return queryset
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Group
|
||||
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
|
||||
|
||||
|
@ -49,6 +49,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
|
||||
return expression
|
||||
|
||||
class Meta:
|
||||
|
||||
model = PropertyMapping
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -31,6 +31,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
return obj.component
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Provider
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -46,6 +46,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
return obj.component
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Source
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -39,6 +39,7 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
|
||||
return attrs
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Token
|
||||
fields = [
|
||||
"pk",
|
||||
@ -133,10 +134,9 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||
def set_key(self, request: Request, identifier: str) -> Response:
|
||||
"""Set token key. Action is logged as event. `authentik_core.set_token_key` permission
|
||||
is required."""
|
||||
"""Return token key and log access"""
|
||||
token: Token = self.get_object()
|
||||
key = request.data.get("key")
|
||||
key = request.POST.get("key")
|
||||
if not key:
|
||||
return Response(status=400)
|
||||
token.key = key
|
||||
|
@ -43,7 +43,6 @@ from rest_framework.serializers import (
|
||||
PrimaryKeyRelatedField,
|
||||
ValidationError,
|
||||
)
|
||||
from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||
from structlog.stdlib import get_logger
|
||||
@ -85,6 +84,7 @@ class UserGroupSerializer(ModelSerializer):
|
||||
parent_name = CharField(source="parent.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Group
|
||||
fields = [
|
||||
"pk",
|
||||
@ -108,7 +108,7 @@ class UserSerializer(ModelSerializer):
|
||||
)
|
||||
groups_obj = ListSerializer(child=UserGroupSerializer(), read_only=True, source="ak_groups")
|
||||
uid = CharField(read_only=True)
|
||||
username = CharField(max_length=150, validators=[UniqueValidator(queryset=User.objects.all())])
|
||||
username = CharField(max_length=150)
|
||||
|
||||
def validate_path(self, path: str) -> str:
|
||||
"""Validate path"""
|
||||
@ -120,6 +120,7 @@ class UserSerializer(ModelSerializer):
|
||||
return path
|
||||
|
||||
class Meta:
|
||||
|
||||
model = User
|
||||
fields = [
|
||||
"pk",
|
||||
@ -171,6 +172,7 @@ class UserSelfSerializer(ModelSerializer):
|
||||
return user.group_attributes(self._context["request"]).get("settings", {})
|
||||
|
||||
class Meta:
|
||||
|
||||
model = User
|
||||
fields = [
|
||||
"pk",
|
||||
@ -400,7 +402,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
response["token"] = token.key
|
||||
return Response(response)
|
||||
except IntegrityError as exc:
|
||||
except (IntegrityError) as exc:
|
||||
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
||||
|
||||
@extend_schema(responses={200: SessionUserSerializer(many=False)})
|
||||
|
@ -14,6 +14,7 @@ import authentik.core.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
@ -43,10 +44,7 @@ class Migration(migrations.Migration):
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"Designates that this user has all permissions without explicitly"
|
||||
" assigning them."
|
||||
),
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
@ -54,9 +52,7 @@ class Migration(migrations.Migration):
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={"unique": "A user with that username already exists."},
|
||||
help_text=(
|
||||
"Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
|
||||
),
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
|
||||
@ -87,10 +83,7 @@ class Migration(migrations.Migration):
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text=(
|
||||
"Designates whether this user should be treated as active. Unselect"
|
||||
" this instead of deleting accounts."
|
||||
),
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
|
@ -51,6 +51,7 @@ def create_default_admin_group(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("authentik_core", "0002_auto_20200523_1133"),
|
||||
("authentik_core", "0003_default_user"),
|
||||
@ -171,10 +172,7 @@ class Migration(migrations.Migration):
|
||||
name="groups",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"The groups this user belongs to. A user will get all permissions granted to"
|
||||
" each of their groups."
|
||||
),
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.Group",
|
||||
|
@ -17,6 +17,7 @@ def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("authentik_core", "0012_auto_20201003_1737"),
|
||||
("authentik_core", "0013_auto_20201003_2132"),
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0016_auto_20201202_2234"),
|
||||
]
|
||||
@ -14,12 +15,7 @@ class Migration(migrations.Migration):
|
||||
name="managed",
|
||||
field=models.TextField(
|
||||
default=None,
|
||||
help_text=(
|
||||
"Objects which are managed by authentik. These objects are created and updated"
|
||||
" automatically. This is flag only indicates that an object can be overwritten"
|
||||
" by migrations. You can still modify the objects via the API, but expect"
|
||||
" changes to be overwritten in a later update."
|
||||
),
|
||||
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
verbose_name="Managed by authentik",
|
||||
unique=True,
|
||||
@ -30,12 +26,7 @@ class Migration(migrations.Migration):
|
||||
name="managed",
|
||||
field=models.TextField(
|
||||
default=None,
|
||||
help_text=(
|
||||
"Objects which are managed by authentik. These objects are created and updated"
|
||||
" automatically. This is flag only indicates that an object can be overwritten"
|
||||
" by migrations. You can still modify the objects via the API, but expect"
|
||||
" changes to be overwritten in a later update."
|
||||
),
|
||||
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
verbose_name="Managed by authentik",
|
||||
unique=True,
|
||||
|
@ -63,6 +63,7 @@ def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("authentik_core", "0018_auto_20210330_1345"),
|
||||
("authentik_core", "0019_source_managed"),
|
||||
@ -95,12 +96,7 @@ class Migration(migrations.Migration):
|
||||
name="managed",
|
||||
field=models.TextField(
|
||||
default=None,
|
||||
help_text=(
|
||||
"Objects which are managed by authentik. These objects are created and updated"
|
||||
" automatically. This is flag only indicates that an object can be overwritten"
|
||||
" by migrations. You can still modify the objects via the API, but expect"
|
||||
" changes to be overwritten in a later update."
|
||||
),
|
||||
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="Managed by authentik",
|
||||
@ -114,38 +110,23 @@ class Migration(migrations.Migration):
|
||||
("identifier", "Use the source-specific identifier"),
|
||||
(
|
||||
"email_link",
|
||||
(
|
||||
"Link to a user with identical email address. Can have security"
|
||||
" implications when a source doesn't validate email addresses."
|
||||
),
|
||||
"Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.",
|
||||
),
|
||||
(
|
||||
"email_deny",
|
||||
(
|
||||
"Use the user's email address, but deny enrollment when the email"
|
||||
" address already exists."
|
||||
),
|
||||
"Use the user's email address, but deny enrollment when the email address already exists.",
|
||||
),
|
||||
(
|
||||
"username_link",
|
||||
(
|
||||
"Link to a user with identical username. Can have security implications"
|
||||
" when a username is used with another source."
|
||||
),
|
||||
"Link to a user with identical username. Can have security implications when a username is used with another source.",
|
||||
),
|
||||
(
|
||||
"username_deny",
|
||||
(
|
||||
"Use the user's username, but deny enrollment when the username already"
|
||||
" exists."
|
||||
),
|
||||
"Use the user's username, but deny enrollment when the username already exists.",
|
||||
),
|
||||
],
|
||||
default="identifier",
|
||||
help_text=(
|
||||
"How the source determines if an existing user should be authenticated or a new"
|
||||
" user enrolled."
|
||||
),
|
||||
help_text="How the source determines if an existing user should be authenticated or a new user enrolled.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0018_auto_20210330_1345_squashed_0028_alter_token_intent"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0019_application_group"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0020_application_open_in_new_tab"),
|
||||
]
|
||||
|
@ -5,6 +5,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0021_source_user_path_user_path"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0022_alter_group_parent"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0023_source_authentik_c_slug_ccb2e5_idx_and_more"),
|
||||
]
|
||||
|
@ -1,7 +1,8 @@
|
||||
"""authentik core models"""
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from hashlib import md5, sha256
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from deepmerge import always_merger
|
||||
@ -12,7 +13,9 @@ from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet, options
|
||||
from django.http import HttpRequest
|
||||
from django.templatetags.static import static
|
||||
from django.utils.functional import SimpleLazyObject, cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from guardian.mixins import GuardianUserMixin
|
||||
@ -24,8 +27,7 @@ from authentik.blueprints.models import ManagedModel
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.signals import password_changed
|
||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||
from authentik.lib.avatars import get_avatar
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.config import CONFIG, get_path_from_dict
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
@ -47,6 +49,9 @@ USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
||||
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
|
||||
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
|
||||
|
||||
GRAVATAR_URL = "https://secure.gravatar.com"
|
||||
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
|
||||
|
||||
|
||||
options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",)
|
||||
|
||||
@ -124,6 +129,7 @@ class Group(SerializerModel):
|
||||
return f"Group {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
unique_together = (
|
||||
(
|
||||
"name",
|
||||
@ -228,9 +234,28 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
|
||||
@property
|
||||
def avatar(self) -> str:
|
||||
"""Get avatar, depending on authentik.avatar setting"""
|
||||
return get_avatar(self)
|
||||
mode: str = CONFIG.y("avatars", "none")
|
||||
if mode == "none":
|
||||
return DEFAULT_AVATAR
|
||||
if mode.startswith("attributes."):
|
||||
return get_path_from_dict(self.attributes, mode[11:], default=DEFAULT_AVATAR)
|
||||
# gravatar uses md5 for their URLs, so md5 can't be avoided
|
||||
mail_hash = md5(self.email.lower().encode("utf-8")).hexdigest() # nosec
|
||||
if mode == "gravatar":
|
||||
parameters = [
|
||||
("s", "158"),
|
||||
("r", "g"),
|
||||
]
|
||||
gravatar_url = f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
|
||||
return escape(gravatar_url)
|
||||
return mode % {
|
||||
"username": self.username,
|
||||
"mail_hash": mail_hash,
|
||||
"upn": self.attributes.get("upn", ""),
|
||||
}
|
||||
|
||||
class Meta:
|
||||
|
||||
permissions = (
|
||||
("reset_user_password", "Reset Password"),
|
||||
("impersonate", "Can impersonate other users"),
|
||||
@ -357,6 +382,7 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
return str(self.name)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Application")
|
||||
verbose_name_plural = _("Applications")
|
||||
|
||||
@ -366,15 +392,19 @@ class SourceUserMatchingModes(models.TextChoices):
|
||||
|
||||
IDENTIFIER = "identifier", _("Use the source-specific identifier")
|
||||
EMAIL_LINK = "email_link", _(
|
||||
"Link to a user with identical email address. Can have security implications "
|
||||
"when a source doesn't validate email addresses."
|
||||
(
|
||||
"Link to a user with identical email address. Can have security implications "
|
||||
"when a source doesn't validate email addresses."
|
||||
)
|
||||
)
|
||||
EMAIL_DENY = "email_deny", _(
|
||||
"Use the user's email address, but deny enrollment when the email address already exists."
|
||||
)
|
||||
USERNAME_LINK = "username_link", _(
|
||||
"Link to a user with identical username. Can have security implications "
|
||||
"when a username is used with another source."
|
||||
(
|
||||
"Link to a user with identical username. Can have security implications "
|
||||
"when a username is used with another source."
|
||||
)
|
||||
)
|
||||
USERNAME_DENY = "username_deny", _(
|
||||
"Use the user's username, but deny enrollment when the username already exists."
|
||||
@ -421,8 +451,10 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
choices=SourceUserMatchingModes.choices,
|
||||
default=SourceUserMatchingModes.IDENTIFIER,
|
||||
help_text=_(
|
||||
"How the source determines if an existing user should be authenticated or "
|
||||
"a new user enrolled."
|
||||
(
|
||||
"How the source determines if an existing user should be authenticated or "
|
||||
"a new user enrolled."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@ -468,6 +500,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
return str(self.name)
|
||||
|
||||
class Meta:
|
||||
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=[
|
||||
@ -496,6 +529,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
||||
raise NotImplementedError
|
||||
|
||||
class Meta:
|
||||
|
||||
unique_together = (("user", "source"),)
|
||||
|
||||
|
||||
@ -528,6 +562,7 @@ class ExpiringModel(models.Model):
|
||||
return now() > self.expires
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
@ -593,6 +628,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
|
||||
return description
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Token")
|
||||
verbose_name_plural = _("Tokens")
|
||||
indexes = [
|
||||
@ -635,6 +671,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
|
||||
return f"Property Mapping {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Property Mapping")
|
||||
verbose_name_plural = _("Property Mappings")
|
||||
|
||||
@ -671,5 +708,6 @@ class AuthenticatedSession(ExpiringModel):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Authenticated Session")
|
||||
verbose_name_plural = _("Authenticated Sessions")
|
||||
|
@ -190,8 +190,11 @@ class SourceFlowManager:
|
||||
# Default case, assume deny
|
||||
error = Exception(
|
||||
_(
|
||||
"Request to authenticate with %(source)s has been denied. Please authenticate "
|
||||
"with the source you've previously signed up with." % {"source": self.source.name}
|
||||
(
|
||||
"Request to authenticate with %(source)s has been denied. Please authenticate "
|
||||
"with the source you've previously signed up with."
|
||||
)
|
||||
% {"source": self.source.name}
|
||||
),
|
||||
)
|
||||
return self.error_handler(error)
|
||||
|
@ -43,12 +43,7 @@ def clean_expired_models(self: MonitoredTask):
|
||||
amount = 0
|
||||
for session in AuthenticatedSession.objects.all():
|
||||
cache_key = f"{KEY_PREFIX}{session.session_key}"
|
||||
value = None
|
||||
try:
|
||||
value = cache.get(cache_key)
|
||||
# pylint: disable=broad-except
|
||||
except Exception as exc:
|
||||
LOGGER.debug("Failed to get session from cache", exc=exc)
|
||||
value = cache.get(cache_key)
|
||||
if not value:
|
||||
session.delete()
|
||||
amount += 1
|
||||
|
@ -13,6 +13,7 @@
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/dropdown.css' %}">
|
||||
{% block head_before %}
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||
|
@ -21,15 +21,9 @@ You've logged out of {{ application }}.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">
|
||||
{% trans 'Go back to overview' %}
|
||||
</a>
|
||||
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">{% trans 'Go back to overview' %}</a>
|
||||
|
||||
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">
|
||||
{% blocktrans with branding_title=tenant.branding_title %}
|
||||
Log out of {{ branding_title }}
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">{% trans 'Log out of authentik' %}</a>
|
||||
|
||||
{% if application.get_launch_url %}
|
||||
<a href="{{ application.get_launch_url }}" class="pf-c-button pf-m-secondary">
|
||||
|
@ -7,7 +7,6 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestTokenAPI(APITestCase):
|
||||
@ -31,28 +30,6 @@ class TestTokenAPI(APITestCase):
|
||||
self.assertEqual(token.expiring, True)
|
||||
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
||||
|
||||
def test_token_set_key(self):
|
||||
"""Test token creation endpoint"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:token-list"), {"identifier": "test-token"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
token = Token.objects.get(identifier="test-token")
|
||||
self.assertEqual(token.user, self.user)
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.expiring, True)
|
||||
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
||||
|
||||
self.client.force_login(self.admin)
|
||||
new_key = generate_id()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:token-set-key", kwargs={"identifier": token.identifier}),
|
||||
{"key": new_key},
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
token.refresh_from_db()
|
||||
self.assertEqual(token.key, new_key)
|
||||
|
||||
def test_token_create_invalid(self):
|
||||
"""Test token creation endpoint (invalid data)"""
|
||||
response = self.client.post(
|
||||
@ -80,7 +57,7 @@ class TestTokenAPI(APITestCase):
|
||||
identifier="test", expiring=False, user=self.user
|
||||
)
|
||||
Token.objects.create(identifier="test-2", expiring=False, user=get_anonymous_user())
|
||||
response = self.client.get(reverse("authentik_api:token-list"))
|
||||
response = self.client.get(reverse(("authentik_api:token-list")))
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 1)
|
||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
||||
@ -94,7 +71,7 @@ class TestTokenAPI(APITestCase):
|
||||
token_should_not: Token = Token.objects.create(
|
||||
identifier="test-2", expiring=False, user=get_anonymous_user()
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:token-list"))
|
||||
response = self.client.get(reverse(("authentik_api:token-list")))
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Test Users API"""
|
||||
from json import loads
|
||||
|
||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||
from django.core.cache import cache
|
||||
@ -8,6 +9,7 @@ from rest_framework.test import APITestCase
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||
from authentik.flows.models import FlowDesignation
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.tenants.models import Tenant
|
||||
@ -220,6 +222,44 @@ class TestUsersAPI(APITestCase):
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@CONFIG.patch("avatars", "none")
|
||||
def test_avatars_none(self):
|
||||
"""Test avatars none"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
|
||||
|
||||
@CONFIG.patch("avatars", "gravatar")
|
||||
def test_avatars_gravatar(self):
|
||||
"""Test avatars gravatar"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertIn("gravatar", body["user"]["avatar"])
|
||||
|
||||
@CONFIG.patch("avatars", "foo-%(username)s")
|
||||
def test_avatars_custom(self):
|
||||
"""Test avatars custom"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["user"]["avatar"], f"foo-{self.admin.username}")
|
||||
|
||||
@CONFIG.patch("avatars", "attributes.foo.avatar")
|
||||
def test_avatars_attributes(self):
|
||||
"""Test avatars attributes"""
|
||||
self.admin.attributes = {"foo": {"avatar": "bar"}}
|
||||
self.admin.save()
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["user"]["avatar"], "bar")
|
||||
|
||||
def test_session_delete(self):
|
||||
"""Ensure sessions are deleted when a user is deactivated"""
|
||||
user = create_test_admin_user()
|
||||
|
@ -1,84 +0,0 @@
|
||||
"""Test Users Avatars"""
|
||||
from json import loads
|
||||
|
||||
from django.urls.base import reverse
|
||||
from requests_mock import Mocker
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class TestUsersAvatars(APITestCase):
|
||||
"""Test Users avatars"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.admin = create_test_admin_user()
|
||||
self.user = User.objects.create(username="test-user")
|
||||
|
||||
@CONFIG.patch("avatars", "none")
|
||||
def test_avatars_none(self):
|
||||
"""Test avatars none"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
|
||||
|
||||
@CONFIG.patch("avatars", "gravatar")
|
||||
def test_avatars_gravatar(self):
|
||||
"""Test avatars gravatar"""
|
||||
self.admin.email = "static@t.goauthentik.io"
|
||||
self.admin.save()
|
||||
self.client.force_login(self.admin)
|
||||
with Mocker() as mocker:
|
||||
mocker.head(
|
||||
(
|
||||
"https://secure.gravatar.com/avatar/84730f9c1851d1ea03f1a"
|
||||
"a9ed85bd1ea?size=158&rating=g&default=404"
|
||||
),
|
||||
text="foo",
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertIn("gravatar", body["user"]["avatar"])
|
||||
|
||||
@CONFIG.patch("avatars", "initials")
|
||||
def test_avatars_initials(self):
|
||||
"""Test avatars initials"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
||||
|
||||
@CONFIG.patch("avatars", "foo://%(username)s")
|
||||
def test_avatars_custom(self):
|
||||
"""Test avatars custom"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["user"]["avatar"], f"foo://{self.admin.username}")
|
||||
|
||||
@CONFIG.patch("avatars", "attributes.foo.avatar")
|
||||
def test_avatars_attributes(self):
|
||||
"""Test avatars attributes"""
|
||||
self.admin.attributes = {"foo": {"avatar": "bar"}}
|
||||
self.admin.save()
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["user"]["avatar"], "bar")
|
||||
|
||||
@CONFIG.patch("avatars", "attributes.foo.avatar,initials")
|
||||
def test_avatars_fallback(self):
|
||||
"""Test fallback"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
|
@ -143,6 +143,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
|
||||
model = CertificateKeyPair
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -6,6 +6,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
@ -35,10 +36,7 @@ class Migration(migrations.Migration):
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text=(
|
||||
"Optional Private Key. If this is set, you can use this keypair for"
|
||||
" encryption."
|
||||
),
|
||||
help_text="Optional Private Key. If this is set, you can use this keypair for encryption.",
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -6,6 +6,7 @@ from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0001_initial"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0002_create_self_signed_kp"),
|
||||
]
|
||||
@ -14,12 +15,7 @@ class Migration(migrations.Migration):
|
||||
name="managed",
|
||||
field=models.TextField(
|
||||
default=None,
|
||||
help_text=(
|
||||
"Objects which are managed by authentik. These objects are created and updated"
|
||||
" automatically. This is flag only indicates that an object can be overwritten"
|
||||
" by migrations. You can still modify the objects via the API, but expect"
|
||||
" changes to be overwritten in a later update."
|
||||
),
|
||||
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="Managed by authentik",
|
||||
|
@ -98,5 +98,6 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
return f"Certificate-Key Pair {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Certificate-Key Pair")
|
||||
verbose_name_plural = _("Certificate-Key Pairs")
|
||||
|
@ -25,6 +25,7 @@ class EventSerializer(ModelSerializer):
|
||||
"""Event Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Event
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -10,6 +10,7 @@ class NotificationWebhookMappingSerializer(ModelSerializer):
|
||||
"""NotificationWebhookMapping Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = NotificationWebhookMapping
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -13,6 +13,7 @@ class NotificationRuleSerializer(ModelSerializer):
|
||||
group_obj = GroupSerializer(read_only=True, source="group")
|
||||
|
||||
class Meta:
|
||||
|
||||
model = NotificationRule
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -43,6 +43,7 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
return attrs
|
||||
|
||||
class Meta:
|
||||
|
||||
model = NotificationTransport
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -25,6 +25,7 @@ class NotificationSerializer(ModelSerializer):
|
||||
event = EventSerializer(required=False)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Notification
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -27,7 +27,6 @@ from authentik.lib.sentry import before_send
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.outposts.models import OutpostServiceConnection
|
||||
from authentik.policies.models import Policy, PolicyBindingModel
|
||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||
|
||||
IGNORED_MODELS = (
|
||||
Event,
|
||||
@ -45,9 +44,6 @@ IGNORED_MODELS = (
|
||||
OutpostServiceConnection,
|
||||
Policy,
|
||||
PolicyBindingModel,
|
||||
AuthorizationCode,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
)
|
||||
|
||||
|
||||
|
@ -100,6 +100,7 @@ def update_expires(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("authentik_events", "0001_initial"),
|
||||
("authentik_events", "0002_auto_20200918_2116"),
|
||||
@ -244,19 +245,14 @@ class Migration(migrations.Migration):
|
||||
models.TextField(
|
||||
choices=[("notice", "Notice"), ("warning", "Warning"), ("alert", "Alert")],
|
||||
default="notice",
|
||||
help_text=(
|
||||
"Controls which severity level the created notifications will have."
|
||||
),
|
||||
help_text="Controls which severity level the created notifications will have.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Define which group of users this notification should be sent and shown"
|
||||
" to. If left empty, Notification won't ben sent."
|
||||
),
|
||||
help_text="Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="authentik_core.group",
|
||||
@ -265,10 +261,7 @@ class Migration(migrations.Migration):
|
||||
(
|
||||
"transports",
|
||||
models.ManyToManyField(
|
||||
help_text=(
|
||||
"Select which transports should be used to notify the user. If none are"
|
||||
" selected, the notification will only be shown in the authentik UI."
|
||||
),
|
||||
help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
|
||||
to="authentik_events.NotificationTransport",
|
||||
blank=True,
|
||||
),
|
||||
@ -324,10 +317,7 @@ class Migration(migrations.Migration):
|
||||
name="send_once",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"Only send notification once, for example when sending a webhook into a chat"
|
||||
" channel."
|
||||
),
|
||||
help_text="Only send notification once, for example when sending a webhook into a chat channel.",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
|
@ -3,6 +3,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0001_squashed_0019_alter_notificationtransport_webhook_url"),
|
||||
]
|
||||
|
@ -283,6 +283,7 @@ class Event(SerializerModel, ExpiringModel):
|
||||
return f"Event action={self.action} user={self.user} context={self.context}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Event")
|
||||
verbose_name_plural = _("Events")
|
||||
|
||||
@ -361,9 +362,7 @@ class NotificationTransport(SerializerModel):
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
raise NotificationTransportError(
|
||||
exc.response.text if exc.response else str(exc)
|
||||
) from exc
|
||||
raise NotificationTransportError(exc.response.text) from exc
|
||||
return [
|
||||
response.status_code,
|
||||
response.text,
|
||||
@ -461,6 +460,7 @@ class NotificationTransport(SerializerModel):
|
||||
return f"Notification Transport {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Notification Transport")
|
||||
verbose_name_plural = _("Notification Transports")
|
||||
|
||||
@ -495,6 +495,7 @@ class Notification(SerializerModel):
|
||||
return f"Notification for user {self.user}: {body_trunc}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Notification")
|
||||
verbose_name_plural = _("Notifications")
|
||||
|
||||
@ -506,8 +507,10 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
|
||||
transports = models.ManyToManyField(
|
||||
NotificationTransport,
|
||||
help_text=_(
|
||||
"Select which transports should be used to notify the user. If none are "
|
||||
"selected, the notification will only be shown in the authentik UI."
|
||||
(
|
||||
"Select which transports should be used to notify the user. If none are "
|
||||
"selected, the notification will only be shown in the authentik UI."
|
||||
)
|
||||
),
|
||||
blank=True,
|
||||
)
|
||||
@ -519,8 +522,10 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
|
||||
group = models.ForeignKey(
|
||||
Group,
|
||||
help_text=_(
|
||||
"Define which group of users this notification should be sent and shown to. "
|
||||
"If left empty, Notification won't ben sent."
|
||||
(
|
||||
"Define which group of users this notification should be sent and shown to. "
|
||||
"If left empty, Notification won't ben sent."
|
||||
)
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
@ -537,6 +542,7 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
|
||||
return f"Notification Rule {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Notification Rule")
|
||||
verbose_name_plural = _("Notification Rules")
|
||||
|
||||
@ -558,5 +564,6 @@ class NotificationWebhookMapping(PropertyMapping):
|
||||
return f"Webhook Mapping {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Webhook Mapping")
|
||||
verbose_name_plural = _("Webhook Mappings")
|
||||
|
@ -63,6 +63,11 @@ class TaskInfo:
|
||||
|
||||
task_description: Optional[str] = field(default=None)
|
||||
|
||||
@property
|
||||
def html_name(self) -> list[str]:
|
||||
"""Get task_name, but split on underscores, so we can join in the html template."""
|
||||
return self.task_name.split("_")
|
||||
|
||||
@staticmethod
|
||||
def all() -> dict[str, "TaskInfo"]:
|
||||
"""Get all TaskInfo objects"""
|
||||
@ -77,7 +82,7 @@ class TaskInfo:
|
||||
"""Delete task info from cache"""
|
||||
return cache.delete(CACHE_KEY_PREFIX + self.task_name)
|
||||
|
||||
def update_metrics(self):
|
||||
def set_prom_metrics(self):
|
||||
"""Update prometheus metrics"""
|
||||
start = default_timer()
|
||||
if hasattr(self, "start_timestamp"):
|
||||
@ -96,9 +101,9 @@ class TaskInfo:
|
||||
"""Save task into cache"""
|
||||
key = CACHE_KEY_PREFIX + self.task_name
|
||||
if self.result.uid:
|
||||
key += f":{self.result.uid}"
|
||||
self.task_name += f":{self.result.uid}"
|
||||
self.update_metrics()
|
||||
key += f"/{self.result.uid}"
|
||||
self.task_name += f"/{self.result.uid}"
|
||||
self.set_prom_metrics()
|
||||
cache.set(key, self, timeout=timeout_hours * 60 * 60)
|
||||
|
||||
|
||||
@ -173,7 +178,7 @@ class MonitoredTask(Task):
|
||||
).save(self.result_timeout_hours)
|
||||
Event.new(
|
||||
EventAction.SYSTEM_TASK_EXCEPTION,
|
||||
message=f"Task {self.__name__} encountered an error: {exception_to_string(exc)}",
|
||||
message=(f"Task {self.__name__} encountered an error: {exception_to_string(exc)}"),
|
||||
).save()
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
|
@ -37,10 +37,11 @@ def event_notification_handler(event_uuid: str):
|
||||
@CELERY_APP.task()
|
||||
def event_trigger_handler(event_uuid: str, trigger_name: str):
|
||||
"""Check if policies attached to NotificationRule match event"""
|
||||
event: Event = Event.objects.filter(event_uuid=event_uuid).first()
|
||||
if not event:
|
||||
events = Event.objects.filter(event_uuid=event_uuid)
|
||||
if not events.exists():
|
||||
LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid)
|
||||
return
|
||||
event: Event = events.first()
|
||||
trigger: Optional[NotificationRule] = NotificationRule.objects.filter(name=trigger_name).first()
|
||||
if not trigger:
|
||||
return
|
||||
|
@ -30,7 +30,7 @@ def cleanse_item(key: str, value: Any) -> Any:
|
||||
"""Cleanse a single item"""
|
||||
if isinstance(value, dict):
|
||||
return cleanse_dict(value)
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
if isinstance(value, list):
|
||||
for idx, item in enumerate(value):
|
||||
value[idx] = cleanse_item(key, item)
|
||||
return value
|
||||
@ -103,7 +103,7 @@ def sanitize_item(value: Any) -> Any:
|
||||
return sanitize_dict(value)
|
||||
if isinstance(value, GeneratorType):
|
||||
return sanitize_item(list(value))
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
if isinstance(value, list):
|
||||
new_values = []
|
||||
for item in value:
|
||||
new_value = sanitize_item(item)
|
||||
|
@ -13,6 +13,7 @@ class FlowStageBindingSerializer(ModelSerializer):
|
||||
stage_obj = StageSerializer(read_only=True, source="stage")
|
||||
|
||||
class Meta:
|
||||
|
||||
model = FlowStageBinding
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -53,6 +53,7 @@ class FlowSerializer(ModelSerializer):
|
||||
return reverse("authentik_api:flow-export", kwargs={"slug": flow.slug})
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Flow
|
||||
fields = [
|
||||
"pk",
|
||||
@ -81,6 +82,7 @@ class FlowSetSerializer(FlowSerializer):
|
||||
"""Stripped down flow serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Flow
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -8,7 +8,7 @@ from rest_framework.serializers import CharField
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.models import Flow, FlowAuthenticationRequirement, FlowStageBinding
|
||||
from authentik.flows.models import Flow, FlowStageBinding
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -160,37 +160,12 @@ class FlowDiagram:
|
||||
)
|
||||
return stages + elements
|
||||
|
||||
def get_flow_auth_requirement(self) -> list[DiagramElement]:
|
||||
"""Get flow authentication requirement"""
|
||||
end_el = DiagramElement(
|
||||
"done",
|
||||
_("End of the flow"),
|
||||
_("Requirement not fulfilled"),
|
||||
style=["[[", "]]"],
|
||||
)
|
||||
elements = []
|
||||
if self.flow.authentication == FlowAuthenticationRequirement.NONE:
|
||||
return []
|
||||
auth = DiagramElement(
|
||||
"flow_auth_requirement",
|
||||
_("Flow authentication requirement") + "\n" + self.flow.authentication,
|
||||
)
|
||||
elements.append(auth)
|
||||
end_el.source = [auth]
|
||||
elements.append(end_el)
|
||||
elements.append(
|
||||
DiagramElement("flow_start", "placeholder", _("Requirement fulfilled"), source=[auth])
|
||||
)
|
||||
return elements
|
||||
|
||||
def build(self) -> str:
|
||||
"""Build flowchart"""
|
||||
all_elements = [
|
||||
"graph TD",
|
||||
]
|
||||
|
||||
all_elements.extend(self.get_flow_auth_requirement())
|
||||
|
||||
pre_flow_policies_element = DiagramElement(
|
||||
"flow_pre", _("Pre-flow policies"), style=["[[", "]]"]
|
||||
)
|
||||
@ -204,7 +179,6 @@ class FlowDiagram:
|
||||
_("End of the flow"),
|
||||
_("Policy denied"),
|
||||
flow_policies,
|
||||
style=["[[", "]]"],
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -33,6 +33,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer):
|
||||
return obj.component
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Stage
|
||||
fields = [
|
||||
"pk",
|
||||
|
@ -7,6 +7,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("authentik_flows", "0001_initial"),
|
||||
("authentik_flows", "0003_auto_20200523_1133"),
|
||||
@ -97,10 +98,7 @@ class Migration(migrations.Migration):
|
||||
"re_evaluate_policies",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"When this option is enabled, the planner will re-evaluate policies"
|
||||
" bound to this."
|
||||
),
|
||||
help_text="When this option is enabled, the planner will re-evaluate policies bound to this.",
|
||||
),
|
||||
),
|
||||
("order", models.IntegerField()),
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0007_auto_20200703_2059"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0008_default_flows"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0009_source_flows"),
|
||||
]
|
||||
|
@ -3,6 +3,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0010_provider_flows"),
|
||||
]
|
||||
|
@ -20,6 +20,7 @@ def update_flow_designation(apps: Apps, schema_editor: BaseDatabaseSchemaEditor)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("authentik_flows", "0012_auto_20200908_1542"),
|
||||
("authentik_flows", "0013_auto_20200924_1605"),
|
||||
@ -78,10 +79,7 @@ class Migration(migrations.Migration):
|
||||
name="re_evaluate_policies",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"When this option is enabled, the planner will re-evaluate policies bound to"
|
||||
" this binding."
|
||||
),
|
||||
help_text="When this option is enabled, the planner will re-evaluate policies bound to this binding.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
@ -96,10 +94,7 @@ class Migration(migrations.Migration):
|
||||
name="evaluate_on_plan",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text=(
|
||||
"Evaluate policies during the Flow planning process. Disable this for"
|
||||
" input-based policies."
|
||||
),
|
||||
help_text="Evaluate policies during the Flow planning process. Disable this for input-based policies.",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
@ -125,10 +120,7 @@ class Migration(migrations.Migration):
|
||||
("recovery", "Recovery"),
|
||||
("stage_configuration", "Stage Configuration"),
|
||||
],
|
||||
help_text=(
|
||||
"Decides what this Flow is used for. For example, the Authentication flow is"
|
||||
" redirect to when an un-authenticated user visits authentik."
|
||||
),
|
||||
help_text="Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik.",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0017_auto_20210329_1334"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("authentik_flows", "0019_alter_flow_background"),
|
||||
("authentik_flows", "0020_flow_compatibility_mode"),
|
||||
@ -38,12 +39,7 @@ class Migration(migrations.Migration):
|
||||
("restart_with_context", "Restart With Context"),
|
||||
],
|
||||
default="retry",
|
||||
help_text=(
|
||||
"Configure how the flow executor should handle an invalid response to a"
|
||||
" challenge. RETRY returns the error message and a similar challenge to the"
|
||||
" executor. RESTART restarts the flow from the beginning, and"
|
||||
" RESTART_WITH_CONTEXT restarts the flow while keeping the current context."
|
||||
),
|
||||
help_text="Configure how the flow executor should handle an invalid response to a challenge. RETRY returns the error message and a similar challenge to the executor. RESTART restarts the flow from the beginning, and RESTART_WITH_CONTEXT restarts the flow while keeping the current context.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
@ -62,10 +58,7 @@ class Migration(migrations.Migration):
|
||||
name="compatibility_mode",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"Enable compatibility mode, increases compatibility with password managers on"
|
||||
" mobile devices."
|
||||
),
|
||||
help_text="Enable compatibility mode, increases compatibility with password managers on mobile devices.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@ -5,6 +5,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0018_auto_20210330_1345_squashed_0028_alter_token_intent"),
|
||||
(
|
||||
|
@ -3,6 +3,7 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0020_flowtoken"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0021_auto_20211227_2103"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0022_flow_layout"),
|
||||
]
|
||||
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0023_flow_denied_action"),
|
||||
]
|
||||
|
@ -135,8 +135,10 @@ class Flow(SerializerModel, PolicyBindingModel):
|
||||
max_length=100,
|
||||
choices=FlowDesignation.choices,
|
||||
help_text=_(
|
||||
"Decides what this Flow is used for. For example, the Authentication flow "
|
||||
"is redirect to when an un-authenticated user visits authentik."
|
||||
(
|
||||
"Decides what this Flow is used for. For example, the Authentication flow "
|
||||
"is redirect to when an un-authenticated user visits authentik."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@ -190,6 +192,7 @@ class Flow(SerializerModel, PolicyBindingModel):
|
||||
return f"Flow {self.name} ({self.slug})"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Flow")
|
||||
verbose_name_plural = _("Flows")
|
||||
|
||||
@ -213,8 +216,10 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
|
||||
evaluate_on_plan = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_(
|
||||
"Evaluate policies during the Flow planning process. "
|
||||
"Disable this for input-based policies."
|
||||
(
|
||||
"Evaluate policies during the Flow planning process. "
|
||||
"Disable this for input-based policies."
|
||||
)
|
||||
),
|
||||
)
|
||||
re_evaluate_policies = models.BooleanField(
|
||||
@ -247,6 +252,7 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
|
||||
return f"Flow-stage binding #{self.order} to {self.target_id}"
|
||||
|
||||
class Meta:
|
||||
|
||||
ordering = ["target", "order"]
|
||||
|
||||
verbose_name = _("Flow Stage Binding")
|
||||
@ -265,12 +271,15 @@ class ConfigurableStage(models.Model):
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Flow used by an authenticated user to configure this Stage. "
|
||||
"If empty, user will not be able to configure this stage."
|
||||
(
|
||||
"Flow used by an authenticated user to configure this Stage. "
|
||||
"If empty, user will not be able to configure this stage."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
@ -296,5 +305,6 @@ class FlowToken(Token):
|
||||
return f"Flow Token {super().__str__()}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Flow Token")
|
||||
verbose_name_plural = _("Flow Tokens")
|
||||
|
@ -207,13 +207,10 @@ class FlowPlanner:
|
||||
) -> FlowPlan:
|
||||
"""Build flow plan by checking each stage in their respective
|
||||
order and checking the applied policies"""
|
||||
with (
|
||||
Hub.current.start_span(
|
||||
op="authentik.flow.planner.build_plan",
|
||||
description=self.flow.slug,
|
||||
) as span,
|
||||
HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time(),
|
||||
):
|
||||
with Hub.current.start_span(
|
||||
op="authentik.flow.planner.build_plan",
|
||||
description=self.flow.slug,
|
||||
) as span, HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time():
|
||||
span: Span
|
||||
span.set_data("flow", self.flow)
|
||||
span.set_data("user", user)
|
||||
|
@ -11,7 +11,7 @@ from rest_framework.request import Request
|
||||
from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import DEFAULT_AVATAR, User
|
||||
from authentik.flows.challenge import (
|
||||
AccessDeniedChallenge,
|
||||
Challenge,
|
||||
@ -24,7 +24,6 @@ from authentik.flows.challenge import (
|
||||
)
|
||||
from authentik.flows.models import InvalidResponseAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.lib.avatars import DEFAULT_AVATAR
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -209,6 +209,7 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
# Here we patch the dummy policy to evaluate to true so the stage is included
|
||||
with patch("authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE):
|
||||
|
||||
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
# First request, run the planner
|
||||
response = self.client.get(exec_url)
|
||||
@ -258,6 +259,7 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
# Here we patch the dummy policy to evaluate to true so the stage is included
|
||||
with patch("authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE):
|
||||
|
||||
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
# First request, run the planner
|
||||
response = self.client.get(exec_url)
|
||||
@ -317,6 +319,7 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
# Here we patch the dummy policy to evaluate to true so the stage is included
|
||||
with patch("authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE):
|
||||
|
||||
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
# First request, run the planner
|
||||
response = self.client.get(exec_url)
|
||||
@ -393,6 +396,7 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
# Here we patch the dummy policy to evaluate to true so the stage is included
|
||||
with patch("authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE):
|
||||
|
||||
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
# First request, run the planner
|
||||
response = self.client.get(exec_url)
|
||||
|
@ -162,7 +162,7 @@ class FlowExecutorView(APIView):
|
||||
token.delete()
|
||||
if not isinstance(plan, FlowPlan):
|
||||
return None
|
||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = True
|
||||
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
|
||||
return plan
|
||||
|
||||
|
@ -1,185 +0,0 @@
|
||||
"""Avatar utils"""
|
||||
from base64 import b64encode
|
||||
from functools import cache
|
||||
from hashlib import md5
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.templatetags.static import static
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element, SubElement # nosec
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from authentik.lib.config import CONFIG, get_path_from_dict
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
|
||||
GRAVATAR_URL = "https://secure.gravatar.com"
|
||||
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.core.models import User
|
||||
|
||||
SVG_XML_NS = "http://www.w3.org/2000/svg"
|
||||
SVG_NS_MAP = {None: SVG_XML_NS}
|
||||
# Match fonts used in web UI
|
||||
SVG_FONTS = [
|
||||
"'RedHatText'",
|
||||
"'Overpass'",
|
||||
"overpass",
|
||||
"helvetica",
|
||||
"arial",
|
||||
"sans-serif",
|
||||
]
|
||||
|
||||
|
||||
def avatar_mode_none(user: "User", mode: str) -> Optional[str]:
|
||||
"""No avatar"""
|
||||
return DEFAULT_AVATAR
|
||||
|
||||
|
||||
def avatar_mode_attribute(user: "User", mode: str) -> Optional[str]:
|
||||
"""Avatars based on a user attribute"""
|
||||
avatar = get_path_from_dict(user.attributes, mode[11:], default=None)
|
||||
return avatar
|
||||
|
||||
|
||||
def avatar_mode_gravatar(user: "User", mode: str) -> Optional[str]:
|
||||
"""Gravatar avatars"""
|
||||
# gravatar uses md5 for their URLs, so md5 can't be avoided
|
||||
mail_hash = md5(user.email.lower().encode("utf-8")).hexdigest() # nosec
|
||||
parameters = [("size", "158"), ("rating", "g"), ("default", "404")]
|
||||
gravatar_url = f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
|
||||
|
||||
@cache
|
||||
def check_non_default(url: str):
|
||||
"""Cache HEAD check, based on URL"""
|
||||
try:
|
||||
# Since we specify a default of 404, do a HEAD request
|
||||
# (HEAD since we don't need the body)
|
||||
# so if that returns a 404, move onto the next mode
|
||||
res = get_http_session().head(url, timeout=5)
|
||||
if res.status_code == 404:
|
||||
return None
|
||||
res.raise_for_status()
|
||||
except RequestException:
|
||||
return url
|
||||
return url
|
||||
|
||||
return check_non_default(gravatar_url)
|
||||
|
||||
|
||||
def generate_colors(text: str) -> tuple[str, str]:
|
||||
"""Generate colours based on `text`"""
|
||||
color = int(md5(text.lower().encode("utf-8")).hexdigest(), 16) % 0xFFFFFF # nosec
|
||||
|
||||
# Get a (somewhat arbitrarily) reduced scope of colors
|
||||
# to avoid too dark or light backgrounds
|
||||
blue = min(max((color) & 0xFF, 55), 200)
|
||||
green = min(max((color >> 8) & 0xFF, 55), 200)
|
||||
red = min(max((color >> 16) & 0xFF, 55), 200)
|
||||
bg_hex = f"{red:02x}{green:02x}{blue:02x}"
|
||||
# Contrasting text color (https://stackoverflow.com/a/3943023)
|
||||
text_hex = "000" if (red * 0.299 + green * 0.587 + blue * 0.114) > 186 else "fff"
|
||||
return bg_hex, text_hex
|
||||
|
||||
|
||||
@cache
|
||||
# pylint: disable=too-many-arguments,too-many-locals
|
||||
def generate_avatar_from_name(
|
||||
name: str,
|
||||
length: int = 2,
|
||||
size: int = 64,
|
||||
rounded: bool = False,
|
||||
font_size: float = 0.4375,
|
||||
bold: bool = False,
|
||||
uppercase: bool = True,
|
||||
) -> str:
|
||||
""" "Generate an avatar with initials in SVG format.
|
||||
|
||||
Inspired from: https://github.com/LasseRafn/ui-avatars
|
||||
"""
|
||||
name_parts = name.split()
|
||||
# Only abbreviate first and last name
|
||||
if len(name_parts) > 2:
|
||||
name_parts = [name_parts[0], name_parts[-1]]
|
||||
|
||||
if len(name_parts) == 1:
|
||||
initials = name_parts[0][:length]
|
||||
else:
|
||||
initials = "".join([part[0] for part in name_parts[:-1]])
|
||||
initials += name_parts[-1]
|
||||
initials = initials[:length]
|
||||
|
||||
bg_hex, text_hex = generate_colors(name)
|
||||
|
||||
half_size = size // 2
|
||||
shape = "circle" if rounded else "rect"
|
||||
font_weight = "600" if bold else "400"
|
||||
|
||||
root_element: Element = Element(f"{{{SVG_XML_NS}}}svg", nsmap=SVG_NS_MAP)
|
||||
root_element.attrib["width"] = f"{size}px"
|
||||
root_element.attrib["height"] = f"{size}px"
|
||||
root_element.attrib["viewBox"] = f"0 0 {size} {size}"
|
||||
root_element.attrib["version"] = "1.1"
|
||||
|
||||
shape = SubElement(root_element, f"{{{SVG_XML_NS}}}{shape}", nsmap=SVG_NS_MAP)
|
||||
shape.attrib["fill"] = f"#{bg_hex}"
|
||||
shape.attrib["cx"] = f"{half_size}"
|
||||
shape.attrib["cy"] = f"{half_size}"
|
||||
shape.attrib["width"] = f"{size}"
|
||||
shape.attrib["height"] = f"{size}"
|
||||
shape.attrib["r"] = f"{half_size}"
|
||||
|
||||
text = SubElement(root_element, f"{{{SVG_XML_NS}}}text", nsmap=SVG_NS_MAP)
|
||||
text.attrib["x"] = "50%"
|
||||
text.attrib["y"] = "50%"
|
||||
text.attrib["style"] = (
|
||||
f"color: #{text_hex}; " "line-height: 1; " f"font-family: {','.join(SVG_FONTS)}; "
|
||||
)
|
||||
text.attrib["fill"] = f"#{text_hex}"
|
||||
text.attrib["alignment-baseline"] = "middle"
|
||||
text.attrib["dominant-baseline"] = "middle"
|
||||
text.attrib["text-anchor"] = "middle"
|
||||
text.attrib["font-size"] = f"{round(size * font_size)}"
|
||||
text.attrib["font-weight"] = f"{font_weight}"
|
||||
text.attrib["dy"] = ".1em"
|
||||
text.text = initials if not uppercase else initials.upper()
|
||||
|
||||
return etree.tostring(root_element).decode()
|
||||
|
||||
|
||||
def avatar_mode_generated(user: "User", mode: str) -> Optional[str]:
|
||||
"""Wrapper that converts generated avatar to base64 svg"""
|
||||
svg = generate_avatar_from_name(user.name if user.name != "" else "a k")
|
||||
return f"data:image/svg+xml;base64,{b64encode(svg.encode('utf-8')).decode('utf-8')}"
|
||||
|
||||
|
||||
def avatar_mode_url(user: "User", mode: str) -> Optional[str]:
|
||||
"""Format url"""
|
||||
mail_hash = md5(user.email.lower().encode("utf-8")).hexdigest() # nosec
|
||||
return mode % {
|
||||
"username": user.username,
|
||||
"mail_hash": mail_hash,
|
||||
"upn": user.attributes.get("upn", ""),
|
||||
}
|
||||
|
||||
|
||||
def get_avatar(user: "User") -> str:
|
||||
"""Get avatar with configured mode"""
|
||||
mode_map = {
|
||||
"none": avatar_mode_none,
|
||||
"initials": avatar_mode_generated,
|
||||
"gravatar": avatar_mode_gravatar,
|
||||
}
|
||||
modes: str = CONFIG.y("avatars", "none")
|
||||
for mode in modes.split(","):
|
||||
avatar = None
|
||||
if mode in mode_map:
|
||||
avatar = mode_map[mode](user, mode)
|
||||
elif mode.startswith("attributes."):
|
||||
avatar = avatar_mode_attribute(user, mode)
|
||||
elif "://" in mode:
|
||||
avatar = avatar_mode_url(user, mode)
|
||||
if avatar:
|
||||
return avatar
|
||||
return avatar_mode_none(user, modes)
|
@ -5,13 +5,20 @@ from contextlib import contextmanager
|
||||
from glob import glob
|
||||
from json import dumps, loads
|
||||
from json.decoder import JSONDecodeError
|
||||
from pathlib import Path
|
||||
from sys import argv, stderr
|
||||
from time import time
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
from django.conf import ImproperlyConfigured
|
||||
from watchdog.events import (
|
||||
FileModifiedEvent,
|
||||
FileSystemEvent,
|
||||
FileSystemEventHandler,
|
||||
)
|
||||
from watchdog.observers import Observer
|
||||
|
||||
SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob(
|
||||
"/etc/authentik/config.d/*.yml", recursive=True
|
||||
@ -38,9 +45,47 @@ class ConfigLoader:
|
||||
A variable like AUTHENTIK_POSTGRESQL__HOST would translate to postgresql.host"""
|
||||
|
||||
loaded_file = []
|
||||
observer: Observer
|
||||
|
||||
class FSObserver(FileSystemEventHandler):
|
||||
"""File system observer"""
|
||||
|
||||
loader: "ConfigLoader"
|
||||
path: str
|
||||
container: Optional[dict] = None
|
||||
key: Optional[str] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
loader: "ConfigLoader",
|
||||
path: str,
|
||||
container: Optional[dict] = None,
|
||||
key: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.loader = loader
|
||||
self.path = path
|
||||
self.container = container
|
||||
self.key = key
|
||||
|
||||
def on_any_event(self, event: FileSystemEvent):
|
||||
if not isinstance(event, FileModifiedEvent):
|
||||
return
|
||||
if event.is_directory:
|
||||
return
|
||||
if event.src_path != self.path:
|
||||
return
|
||||
if self.container and self.key:
|
||||
with open(self.path, "r", encoding="utf8") as _file:
|
||||
self.container[self.key] = _file.read()
|
||||
else:
|
||||
self.loader.log("info", "Updating from changed file", file=self.path)
|
||||
self.loader.update_from_file(self.path, watch=False)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.observer = Observer()
|
||||
self.observer.start()
|
||||
self.__config = {}
|
||||
base_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "../.."))
|
||||
for path in SEARCH_PATHS:
|
||||
@ -81,11 +126,11 @@ class ConfigLoader:
|
||||
root[key] = self.update(root.get(key, {}), value)
|
||||
else:
|
||||
if isinstance(value, str):
|
||||
value = self.parse_uri(value)
|
||||
value = self.parse_uri(value, root, key)
|
||||
root[key] = value
|
||||
return root
|
||||
|
||||
def parse_uri(self, value: str) -> str:
|
||||
def parse_uri(self, value: str, container: dict[str, Any], key: Optional[str] = None, ) -> str:
|
||||
"""Parse string values which start with a URI"""
|
||||
url = urlparse(value)
|
||||
if url.scheme == "env":
|
||||
@ -93,13 +138,23 @@ class ConfigLoader:
|
||||
if url.scheme == "file":
|
||||
try:
|
||||
with open(url.path, "r", encoding="utf8") as _file:
|
||||
value = _file.read().strip()
|
||||
value = _file.read()
|
||||
if key:
|
||||
self.observer.schedule(
|
||||
ConfigLoader.FSObserver(
|
||||
self,
|
||||
url.path,
|
||||
container,
|
||||
key,
|
||||
),
|
||||
Path(url.path).parent,
|
||||
)
|
||||
except OSError as exc:
|
||||
self.log("error", f"Failed to read config value from {url.path}: {exc}")
|
||||
value = url.query
|
||||
return value
|
||||
|
||||
def update_from_file(self, path: str):
|
||||
def update_from_file(self, path: str, watch=True):
|
||||
"""Update config from file contents"""
|
||||
try:
|
||||
with open(path, encoding="utf8") as file:
|
||||
@ -107,6 +162,8 @@ class ConfigLoader:
|
||||
self.update(self.__config, yaml.safe_load(file))
|
||||
self.log("debug", "Loaded config", file=path)
|
||||
self.loaded_file.append(path)
|
||||
if watch:
|
||||
self.observer.schedule(ConfigLoader.FSObserver(self, path), Path(path).parent)
|
||||
except yaml.YAMLError as exc:
|
||||
raise ImproperlyConfigured from exc
|
||||
except PermissionError as exc:
|
||||
@ -181,13 +238,12 @@ class ConfigLoader:
|
||||
if comp not in root:
|
||||
root[comp] = {}
|
||||
root = root.get(comp, {})
|
||||
root[path_parts[-1]] = value
|
||||
self.parse_uri(value, root, path_parts[-1])
|
||||
|
||||
def y_bool(self, path: str, default=False) -> bool:
|
||||
"""Wrapper for y that converts value into boolean"""
|
||||
return str(self.y(path, default)).lower() == "true"
|
||||
|
||||
|
||||
CONFIG = ConfigLoader()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -71,7 +71,7 @@ ldap:
|
||||
cookie_domain: null
|
||||
disable_update_check: false
|
||||
disable_startup_analytics: false
|
||||
avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar,initials
|
||||
avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar
|
||||
geoip: "/geoip/GeoLite2-City.mmdb"
|
||||
|
||||
footer_links: []
|
||||
|
@ -5,7 +5,7 @@ from tempfile import mkstemp
|
||||
from django.conf import ImproperlyConfigured
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.lib.config import ENV_PREFIX, ConfigLoader
|
||||
from authentik.lib.config import CONFIG, ENV_PREFIX, ConfigLoader
|
||||
|
||||
|
||||
class TestConfig(TestCase):
|
||||
@ -31,8 +31,8 @@ class TestConfig(TestCase):
|
||||
"""Test URI parsing (environment)"""
|
||||
config = ConfigLoader()
|
||||
environ["foo"] = "bar"
|
||||
self.assertEqual(config.parse_uri("env://foo"), "bar")
|
||||
self.assertEqual(config.parse_uri("env://foo?bar"), "bar")
|
||||
self.assertEqual(config.parse_uri("env://foo", {}), "bar")
|
||||
self.assertEqual(config.parse_uri("env://foo?bar", {}), "bar")
|
||||
|
||||
def test_uri_file(self):
|
||||
"""Test URI parsing (file load)"""
|
||||
@ -41,8 +41,8 @@ class TestConfig(TestCase):
|
||||
write(file, "foo".encode())
|
||||
_, file2_name = mkstemp()
|
||||
chmod(file2_name, 0o000) # Remove all permissions so we can't read the file
|
||||
self.assertEqual(config.parse_uri(f"file://{file_name}"), "foo")
|
||||
self.assertEqual(config.parse_uri(f"file://{file2_name}?def"), "def")
|
||||
self.assertEqual(config.parse_uri(f"file://{file_name}", {}), "foo")
|
||||
self.assertEqual(config.parse_uri(f"file://{file2_name}?def", {}), "def")
|
||||
unlink(file_name)
|
||||
unlink(file2_name)
|
||||
|
||||
@ -59,3 +59,13 @@ class TestConfig(TestCase):
|
||||
config.update_from_file(file2_name)
|
||||
unlink(file_name)
|
||||
unlink(file2_name)
|
||||
|
||||
def test_update(self):
|
||||
"""Test change to file"""
|
||||
file, file_name = mkstemp()
|
||||
write(file, b"test")
|
||||
CONFIG.y_set("test.file", f"file://{file_name}")
|
||||
self.assertEqual(CONFIG.y("test.file"), "test")
|
||||
write(file, "test2")
|
||||
self.assertEqual(CONFIG.y("test.file"), "test2")
|
||||
unlink(file_name)
|
||||
|
@ -27,9 +27,10 @@ def redirect_with_qs(
|
||||
return redirect(view)
|
||||
LOGGER.warning("redirect target is not a valid view", view=view)
|
||||
raise
|
||||
if get_query_set:
|
||||
target += "?" + urlencode(get_query_set.items())
|
||||
return redirect(target)
|
||||
else:
|
||||
if get_query_set:
|
||||
target += "?" + urlencode(get_query_set.items())
|
||||
return redirect(target)
|
||||
|
||||
|
||||
def reverse_with_qs(view: str, query: Optional[QueryDict] = None, **kwargs) -> str:
|
||||
|
@ -56,8 +56,10 @@ class OutpostSerializer(ModelSerializer):
|
||||
for provider in providers:
|
||||
if not isinstance(provider, type_map[self.initial_data.get("type")]):
|
||||
raise ValidationError(
|
||||
f"Outpost type {self.initial_data['type']} can't be used with "
|
||||
f"{provider.__class__.__name__} providers."
|
||||
(
|
||||
f"Outpost type {self.initial_data['type']} can't be used with "
|
||||
f"{provider.__class__.__name__} providers."
|
||||
)
|
||||
)
|
||||
if self.instance and self.instance.managed == MANAGED_OUTPOST:
|
||||
return providers
|
||||
@ -74,6 +76,7 @@ class OutpostSerializer(ModelSerializer):
|
||||
return config
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Outpost
|
||||
fields = [
|
||||
"pk",
|
||||
@ -121,6 +124,7 @@ class OutpostFilter(FilterSet):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Outpost
|
||||
fields = {
|
||||
"providers": ["isnull"],
|
||||
|
@ -37,6 +37,7 @@ class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer):
|
||||
return obj.component
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OutpostServiceConnection
|
||||
fields = [
|
||||
"pk",
|
||||
@ -100,6 +101,7 @@ class DockerServiceConnectionSerializer(ServiceConnectionSerializer):
|
||||
"""DockerServiceConnection Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = DockerServiceConnection
|
||||
fields = ServiceConnectionSerializer.Meta.fields + [
|
||||
"url",
|
||||
@ -138,6 +140,7 @@ class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
|
||||
return kubeconfig
|
||||
|
||||
class Meta:
|
||||
|
||||
model = KubernetesServiceConnection
|
||||
fields = ServiceConnectionSerializer.Meta.fields + ["kubeconfig", "verify_ssl"]
|
||||
|
||||
|
@ -73,7 +73,8 @@ class KubernetesObjectReconciler(Generic[T]):
|
||||
raise NeedsRecreate from exc
|
||||
self.logger.debug("Other unhandled error", exc=exc)
|
||||
raise exc
|
||||
self.reconcile(current, reference)
|
||||
else:
|
||||
self.reconcile(current, reference)
|
||||
except NeedsUpdate:
|
||||
try:
|
||||
self.update(current, reference)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user