core: Add support for auto generating unique avatars based on the user's initials (#4663)

This commit is contained in:
sdimovv 2023-02-12 15:35:17 +00:00 committed by GitHub
parent 21e29744c2
commit b69e55eae9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 285 additions and 72 deletions

View File

@ -1,8 +1,7 @@
"""authentik core models""" """authentik core models"""
from datetime import timedelta from datetime import timedelta
from hashlib import md5, sha256 from hashlib import sha256
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlencode
from uuid import uuid4 from uuid import uuid4
from deepmerge import always_merger 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 import models
from django.db.models import Q, QuerySet, options from django.db.models import Q, QuerySet, options
from django.http import HttpRequest from django.http import HttpRequest
from django.templatetags.static import static
from django.utils.functional import SimpleLazyObject, cached_property from django.utils.functional import SimpleLazyObject, cached_property
from django.utils.html import escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from guardian.mixins import GuardianUserMixin from guardian.mixins import GuardianUserMixin
@ -27,7 +24,8 @@ from authentik.blueprints.models import ManagedModel
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, 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.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
@ -49,9 +47,6 @@ USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
USER_PATH_SYSTEM_PREFIX = "goauthentik.io" USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts" 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",) options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",)
@ -233,25 +228,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
@property @property
def avatar(self) -> str: def avatar(self) -> str:
"""Get avatar, depending on authentik.avatar setting""" """Get avatar, depending on authentik.avatar setting"""
mode: str = CONFIG.y("avatars", "none") return get_avatar(self)
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", ""),
}
class Meta: class Meta:
permissions = ( permissions = (

View File

@ -1,5 +1,4 @@
"""Test Users API""" """Test Users API"""
from json import loads
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache 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.models import AuthenticatedSession, 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
@ -222,44 +220,6 @@ class TestUsersAPI(APITestCase):
response = self.client.get(reverse("authentik_api:user-me")) response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200) 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): def test_session_delete(self):
"""Ensure sessions are deleted when a user is deactivated""" """Ensure sessions are deleted when a user is deactivated"""
user = create_test_admin_user() user = create_test_admin_user()

View 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"])

View File

@ -11,7 +11,7 @@ from rest_framework.request import Request
from sentry_sdk.hub import Hub from sentry_sdk.hub import Hub
from structlog.stdlib import BoundLogger, get_logger 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 ( from authentik.flows.challenge import (
AccessDeniedChallenge, AccessDeniedChallenge,
Challenge, Challenge,
@ -24,6 +24,7 @@ from authentik.flows.challenge import (
) )
from authentik.flows.models import InvalidResponseAction from authentik.flows.models import InvalidResponseAction
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER 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 from authentik.lib.utils.reflection import class_to_path
if TYPE_CHECKING: if TYPE_CHECKING:

187
authentik/lib/avatars.py Normal file
View 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)

View File

@ -71,7 +71,7 @@ ldap:
cookie_domain: null cookie_domain: null
disable_update_check: false disable_update_check: false
disable_startup_analytics: false disable_startup_analytics: false
avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar,initials
geoip: "/geoip/GeoLite2-City.mmdb" geoip: "/geoip/GeoLite2-City.mmdb"
footer_links: [] footer_links: []

View File

@ -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: 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 - `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. - Any URL: If you want to use images hosted on another server, you can set any URL.
Additionally, these placeholders can be used: 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 - `%(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`, 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.
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` ### `AUTHENTIK_DEFAULT_USER_CHANGE_NAME`