diff --git a/authentik/core/models.py b/authentik/core/models.py index a1f41f02d..f81e846e1 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -1,8 +1,7 @@ """authentik core models""" from datetime import timedelta -from hashlib import md5, sha256 +from hashlib import sha256 from typing import Any, Optional -from urllib.parse import urlencode from uuid import uuid4 from deepmerge import always_merger @@ -13,9 +12,7 @@ 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 @@ -27,7 +24,8 @@ 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.config import CONFIG, get_path_from_dict +from authentik.lib.avatars import get_avatar +from authentik.lib.config import CONFIG from authentik.lib.generators import generate_id from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel from authentik.lib.utils.http import get_client_ip @@ -49,9 +47,6 @@ 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",) @@ -233,25 +228,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): @property def avatar(self) -> str: """Get avatar, depending on authentik.avatar setting""" - 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", ""), - } + return get_avatar(self) class Meta: permissions = ( diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index df1d7edcc..ab76de49b 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -1,5 +1,4 @@ """Test Users API""" -from json import loads from django.contrib.sessions.backends.cache import KEY_PREFIX from django.core.cache import cache @@ -9,7 +8,6 @@ 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 @@ -222,44 +220,6 @@ 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() diff --git a/authentik/core/tests/test_users_avatars.py b/authentik/core/tests/test_users_avatars.py new file mode 100644 index 000000000..cce239354 --- /dev/null +++ b/authentik/core/tests/test_users_avatars.py @@ -0,0 +1,84 @@ +"""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"]) diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index 2d36e3d0b..0962cf4a8 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -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 DEFAULT_AVATAR, User +from authentik.core.models import User from authentik.flows.challenge import ( AccessDeniedChallenge, Challenge, @@ -24,6 +24,7 @@ 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: diff --git a/authentik/lib/avatars.py b/authentik/lib/avatars.py new file mode 100644 index 000000000..634cf6485 --- /dev/null +++ b/authentik/lib/avatars.py @@ -0,0 +1,187 @@ +"""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( + user: "User", + 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 = user.name if user.name != "" else "a k" + + 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) + 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) diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 5ae2e896d..1617f445d 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -71,7 +71,7 @@ ldap: cookie_domain: null disable_update_check: false disable_startup_analytics: false -avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar +avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar,initials geoip: "/geoip/GeoLite2-City.mmdb" footer_links: [] diff --git a/website/docs/installation/configuration.md b/website/docs/installation/configuration.md index edaac9f3b..97577e910 100644 --- a/website/docs/installation/configuration.md +++ b/website/docs/installation/configuration.md @@ -177,8 +177,11 @@ Disable the inbuilt update-checker. Defaults to `false`. Configure how authentik should show avatars for users. Following values can be set: +Default: `gravatar,initials` + - `none`: Disables per-user avatars and just shows a 1x1 pixel transparent picture -- `gravatar`: Uses gravatar with the user's email address (default) +- `gravatar`: Uses gravatar with the user's email address +- `initials`: Generated avatars based on the user's name - Any URL: If you want to use images hosted on another server, you can set any URL. Additionally, these placeholders can be used: @@ -187,8 +190,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. +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. + +Starting with authentik 2023.2, multiple modes can be set, and authentik will fallback to the next mode when no avatar could be found. For example, setting this to `gravatar,initials` will attempt to get an avatar from Gravatar, and if the user has not configured on there, it will fallback to a generated avatar. ### `AUTHENTIK_DEFAULT_USER_CHANGE_NAME`