From de26c65fa0513f21353a83bb8140f9da09707a2c Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 26 Jul 2022 21:41:55 +0200 Subject: [PATCH] core: add attributes. avatar method to allow custom uploaded avatars Signed-off-by: Jens Langhammer #2631 --- authentik/core/models.py | 6 ++- authentik/core/tests/test_users_api.py | 47 ++++++++++++++++++++++ authentik/lib/config.py | 18 ++++++--- authentik/stages/prompt/models.py | 11 +---- website/docs/installation/configuration.md | 3 ++ 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/authentik/core/models.py b/authentik/core/models.py index 74116d5ad..c6e6cc7c3 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -26,7 +26,7 @@ from structlog.stdlib import get_logger from authentik.core.exceptions import PropertyMappingExpressionException from authentik.core.signals import password_changed from authentik.core.types import UILoginButton, UserSettingSerializer -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 @@ -213,9 +213,11 @@ class User(GuardianUserMixin, AbstractUser): mode: str = CONFIG.y("avatars", "none") if mode == "none": return DEFAULT_AVATAR - # gravatar uses md5 for their URLs, so md5 can't be avoided + if mode.startswith("attributes."): + return get_path_from_dict(self.attributes, mode[11:], default=DEFAULT_AVATAR) mail_hash = md5(self.email.lower().encode("utf-8")).hexdigest() # nosec if mode == "gravatar": + # gravatar uses md5 for their URLs, so md5 can't be avoided parameters = [ ("s", "158"), ("r", "g"), diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 210b0de62..0d5985dba 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -1,10 +1,13 @@ """Test Users API""" +from json import loads + from django.urls.base import reverse from rest_framework.test import APITestCase from authentik.core.models import User from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant from authentik.flows.models import 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 @@ -211,3 +214,47 @@ class TestUsersAPI(APITestCase): self.assertJSONEqual( response.content.decode(), {"path": ["No empty segments in user path allowed."]} ) + + def test_me(self): + """Test user's me endpoint""" + self.client.force_login(self.admin) + 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") diff --git a/authentik/lib/config.py b/authentik/lib/config.py index 5df6a81af..02e64de13 100644 --- a/authentik/lib/config.py +++ b/authentik/lib/config.py @@ -20,6 +20,17 @@ ENV_PREFIX = "AUTHENTIK" ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local") +def get_path_from_dict(root: dict, path: str, sep=".", default=None): + """Recursively walk through `root`, checking each part of `path` split by `sep`. + If at any point a dict does not exist, return default""" + for comp in path.split(sep): + if root and comp in root: + root = root.get(comp) + else: + return default + return root + + class ConfigLoader: """Search through SEARCH_PATHS and load configuration. Environment variables starting with `ENV_PREFIX` are also applied. @@ -155,12 +166,7 @@ class ConfigLoader: # Walk sub_dicts before parsing path root = self.raw # Walk each component of the path - for comp in path.split(sep): - if root and comp in root: - root = root.get(comp) - else: - return default - return root + return get_path_from_dict(root, path, sep=sep, default=default) def y_set(self, path: str, value: Any, sep="."): """Set value using same syntax as y()""" diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py index 67d56c03a..eb70b97c0 100644 --- a/authentik/stages/prompt/models.py +++ b/authentik/stages/prompt/models.py @@ -1,6 +1,4 @@ """prompt models""" -from base64 import b64decode -from binascii import Error from typing import Any, Optional from urllib.parse import urlparse from uuid import uuid4 @@ -87,16 +85,11 @@ class InlineFileField(CharField): uri = urlparse(data) if uri.scheme != "data": raise ValidationError("Invalid scheme") - header, encoded = uri.path.split(",", 1) + header, _encoded = uri.path.split(",", 1) _mime, _, enc = header.partition(";") if enc != "base64": raise ValidationError("Invalid encoding") - try: - data = b64decode(encoded.encode()).decode() - except (UnicodeDecodeError, UnicodeEncodeError, ValueError, Error): - LOGGER.info("failed to decode base64 of file field, keeping base64") - data = encoded - return super().to_internal_value(data) + return super().to_internal_value(uri) class Prompt(SerializerModel): diff --git a/website/docs/installation/configuration.md b/website/docs/installation/configuration.md index c55dde475..02ab63992 100644 --- a/website/docs/installation/configuration.md +++ b/website/docs/installation/configuration.md @@ -149,6 +149,9 @@ Configure how authentik should show avatars for users. Following values can be s - `%(mail_hash)s`: The email address, md5 hashed - `%(upn)s`: The user's UPN, if set (otherwise an empty string) +Starting with authentik 2022.8, you can also use an attribute path like `attributes.something.avatar`, +which can be used in combination with the file field to allow users to upload custom avatars for themselves. + ### `AUTHENTIK_DEFAULT_USER_CHANGE_NAME` :::info