Compare commits

..

1 Commits

Author SHA1 Message Date
3fa987f443 start config file watch
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-01-25 21:32:33 +01:00
537 changed files with 4287 additions and 8614 deletions

View File

@ -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+)

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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/

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -46,6 +46,5 @@
"url": "https://github.com/goauthentik/authentik/issues/<num>",
"ignoreCase": false
}
],
"go.testFlags": ["-count=1"]
]
}

View File

@ -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.

View File

@ -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.

View File

@ -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"

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -58,6 +58,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
return super().validate(attrs)
class Meta:
model = BlueprintInstance
fields = [
"pk",

View File

@ -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",

View File

@ -4,6 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_blueprints", "0001_initial"),
]

View File

@ -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 = (

View File

@ -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())

View File

@ -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:

View File

@ -56,4 +56,5 @@ class MetaApplyBlueprint(BaseMetaModel):
return ApplyBlueprintMetaSerializer
class Meta:
abstract = True

View File

@ -14,6 +14,7 @@ class BaseMetaModel(Model):
raise NotImplementedError
class Meta:
abstract = True

View File

@ -63,6 +63,7 @@ class ApplicationSerializer(ModelSerializer):
return app.get_launch_url(user)
class Meta:
model = Application
fields = [
"pk",

View File

@ -74,6 +74,7 @@ class AuthenticatedSessionSerializer(ModelSerializer):
return GEOIP_READER.city_dict(instance.last_ip)
class Meta:
model = AuthenticatedSession
fields = [
"uuid",

View File

@ -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"]

View File

@ -49,6 +49,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
return expression
class Meta:
model = PropertyMapping
fields = [
"pk",

View File

@ -31,6 +31,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
return obj.component
class Meta:
model = Provider
fields = [
"pk",

View File

@ -46,6 +46,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
return obj.component
class Meta:
model = Source
fields = [
"pk",

View File

@ -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

View File

@ -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)})

View File

@ -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",
),
),

View File

@ -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",

View File

@ -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"),

View File

@ -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,

View File

@ -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(

View File

@ -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"),
]

View File

@ -4,6 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0019_application_group"),
]

View File

@ -4,6 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0020_application_open_in_new_tab"),
]

View File

@ -5,6 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0021_source_user_path_user_path"),
]

View File

@ -4,6 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0022_alter_group_parent"),
]

View File

@ -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"),
]

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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' %}">

View File

@ -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">

View File

@ -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)

View File

@ -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()

View File

@ -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"])

View File

@ -143,6 +143,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
return value
class Meta:
model = CertificateKeyPair
fields = [
"pk",

View File

@ -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.",
),
),
],

View File

@ -6,6 +6,7 @@ from authentik.lib.generators import generate_id
class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0001_initial"),
]

View File

@ -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",

View File

@ -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")

View File

@ -25,6 +25,7 @@ class EventSerializer(ModelSerializer):
"""Event Serializer"""
class Meta:
model = Event
fields = [
"pk",

View File

@ -10,6 +10,7 @@ class NotificationWebhookMappingSerializer(ModelSerializer):
"""NotificationWebhookMapping Serializer"""
class Meta:
model = NotificationWebhookMapping
fields = [
"pk",

View File

@ -13,6 +13,7 @@ class NotificationRuleSerializer(ModelSerializer):
group_obj = GroupSerializer(read_only=True, source="group")
class Meta:
model = NotificationRule
fields = [
"pk",

View File

@ -43,6 +43,7 @@ class NotificationTransportSerializer(ModelSerializer):
return attrs
class Meta:
model = NotificationTransport
fields = [
"pk",

View File

@ -25,6 +25,7 @@ class NotificationSerializer(ModelSerializer):
event = EventSerializer(required=False)
class Meta:
model = Notification
fields = [
"pk",

View File

@ -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,
)

View File

@ -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(

View File

@ -3,6 +3,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0001_squashed_0019_alter_notificationtransport_webhook_url"),
]

View File

@ -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")

View File

@ -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):

View File

@ -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

View File

@ -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)

View File

@ -13,6 +13,7 @@ class FlowStageBindingSerializer(ModelSerializer):
stage_obj = StageSerializer(read_only=True, source="stage")
class Meta:
model = FlowStageBinding
fields = [
"pk",

View File

@ -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",

View File

@ -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=["[[", "]]"],
)
)

View File

@ -33,6 +33,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer):
return obj.component
class Meta:
model = Stage
fields = [
"pk",

View File

@ -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()),

View File

@ -4,6 +4,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0007_auto_20200703_2059"),
]

View File

@ -4,6 +4,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0008_default_flows"),
]

View File

@ -4,6 +4,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0009_source_flows"),
]

View File

@ -3,6 +3,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0010_provider_flows"),
]

View File

@ -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,
),
),

View File

@ -4,6 +4,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0017_auto_20210329_1334"),
]

View File

@ -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.",
),
),
]

View File

@ -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"),
(

View File

@ -3,6 +3,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0020_flowtoken"),
]

View File

@ -4,6 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0021_auto_20211227_2103"),
]

View File

@ -4,6 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0022_flow_layout"),
]

View File

@ -4,6 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0023_flow_denied_action"),
]

View File

@ -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")

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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__":

View File

@ -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: []

View File

@ -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)

View File

@ -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:

View File

@ -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"],

View File

@ -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"]

View File

@ -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