core: add attributes. avatar method to allow custom uploaded avatars
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> #2631
This commit is contained in:
		| @ -26,7 +26,7 @@ from structlog.stdlib import get_logger | |||||||
| from authentik.core.exceptions import PropertyMappingExpressionException | from authentik.core.exceptions import PropertyMappingExpressionException | ||||||
| from authentik.core.signals import password_changed | from authentik.core.signals import password_changed | ||||||
| from authentik.core.types import UILoginButton, UserSettingSerializer | from authentik.core.types import UILoginButton, UserSettingSerializer | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG, get_path_from_dict | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel | from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel | ||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| @ -213,9 +213,11 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|         mode: str = CONFIG.y("avatars", "none") |         mode: str = CONFIG.y("avatars", "none") | ||||||
|         if mode == "none": |         if mode == "none": | ||||||
|             return DEFAULT_AVATAR |             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 |         mail_hash = md5(self.email.lower().encode("utf-8")).hexdigest()  # nosec | ||||||
|         if mode == "gravatar": |         if mode == "gravatar": | ||||||
|  |             # gravatar uses md5 for their URLs, so md5 can't be avoided | ||||||
|             parameters = [ |             parameters = [ | ||||||
|                 ("s", "158"), |                 ("s", "158"), | ||||||
|                 ("r", "g"), |                 ("r", "g"), | ||||||
|  | |||||||
| @ -1,10 +1,13 @@ | |||||||
| """Test Users API""" | """Test Users API""" | ||||||
|  | from json import loads | ||||||
|  |  | ||||||
| from django.urls.base import reverse | from django.urls.base import reverse | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant | from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant | ||||||
| from authentik.flows.models import FlowDesignation | from authentik.flows.models import FlowDesignation | ||||||
|  | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.generators import generate_id, generate_key | from authentik.lib.generators import generate_id, generate_key | ||||||
| from authentik.stages.email.models import EmailStage | from authentik.stages.email.models import EmailStage | ||||||
| from authentik.tenants.models import Tenant | from authentik.tenants.models import Tenant | ||||||
| @ -211,3 +214,47 @@ class TestUsersAPI(APITestCase): | |||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             response.content.decode(), {"path": ["No empty segments in user path allowed."]} |             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") | ||||||
|  | |||||||
| @ -20,6 +20,17 @@ ENV_PREFIX = "AUTHENTIK" | |||||||
| ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local") | 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: | class ConfigLoader: | ||||||
|     """Search through SEARCH_PATHS and load configuration. Environment variables starting with |     """Search through SEARCH_PATHS and load configuration. Environment variables starting with | ||||||
|     `ENV_PREFIX` are also applied. |     `ENV_PREFIX` are also applied. | ||||||
| @ -155,12 +166,7 @@ class ConfigLoader: | |||||||
|         # Walk sub_dicts before parsing path |         # Walk sub_dicts before parsing path | ||||||
|         root = self.raw |         root = self.raw | ||||||
|         # Walk each component of the path |         # Walk each component of the path | ||||||
|         for comp in path.split(sep): |         return get_path_from_dict(root, path, sep=sep, default=default) | ||||||
|             if root and comp in root: |  | ||||||
|                 root = root.get(comp) |  | ||||||
|             else: |  | ||||||
|                 return default |  | ||||||
|         return root |  | ||||||
|  |  | ||||||
|     def y_set(self, path: str, value: Any, sep="."): |     def y_set(self, path: str, value: Any, sep="."): | ||||||
|         """Set value using same syntax as y()""" |         """Set value using same syntax as y()""" | ||||||
|  | |||||||
| @ -1,6 +1,4 @@ | |||||||
| """prompt models""" | """prompt models""" | ||||||
| from base64 import b64decode |  | ||||||
| from binascii import Error |  | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
| from urllib.parse import urlparse | from urllib.parse import urlparse | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
| @ -87,16 +85,11 @@ class InlineFileField(CharField): | |||||||
|         uri = urlparse(data) |         uri = urlparse(data) | ||||||
|         if uri.scheme != "data": |         if uri.scheme != "data": | ||||||
|             raise ValidationError("Invalid scheme") |             raise ValidationError("Invalid scheme") | ||||||
|         header, encoded = uri.path.split(",", 1) |         header, _encoded = uri.path.split(",", 1) | ||||||
|         _mime, _, enc = header.partition(";") |         _mime, _, enc = header.partition(";") | ||||||
|         if enc != "base64": |         if enc != "base64": | ||||||
|             raise ValidationError("Invalid encoding") |             raise ValidationError("Invalid encoding") | ||||||
|         try: |         return super().to_internal_value(uri) | ||||||
|             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) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Prompt(SerializerModel): | class Prompt(SerializerModel): | ||||||
|  | |||||||
| @ -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 |     -   `%(mail_hash)s`: The email address, md5 hashed | ||||||
|     -   `%(upn)s`: The user's UPN, if set (otherwise an empty string) |     -   `%(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` | ### `AUTHENTIK_DEFAULT_USER_CHANGE_NAME` | ||||||
|  |  | ||||||
| :::info | :::info | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer