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:
parent
55739ee982
commit
de26c65fa0
|
@ -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