core: Add support for auto generating unique avatars based on the user's initials (#4663)
This commit is contained in:
parent
21e29744c2
commit
b69e55eae9
|
@ -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 = (
|
||||
|
|
|
@ -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()
|
||||
|
|
84
authentik/core/tests/test_users_avatars.py
Normal file
84
authentik/core/tests/test_users_avatars.py
Normal file
|
@ -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"])
|
|
@ -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:
|
||||
|
|
187
authentik/lib/avatars.py
Normal file
187
authentik/lib/avatars.py
Normal file
|
@ -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)
|
|
@ -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: []
|
||||
|
|
|
@ -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`
|
||||
|
||||
|
|
Reference in a new issue