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:
Jens Langhammer 2022-07-26 21:41:55 +02:00
parent 55739ee982
commit de26c65fa0
5 changed files with 68 additions and 17 deletions

View file

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

View file

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

View file

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

View file

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

View file

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