authentik.lib.avatars

Avatar utils

  1"""Avatar utils"""
  2
  3from base64 import b64encode
  4from functools import cache as funccache
  5from hashlib import md5, sha256
  6from typing import TYPE_CHECKING
  7from urllib.parse import urlencode, urlparse
  8
  9from django.core.cache import cache
 10from django.http import HttpRequest, HttpResponseNotFound
 11from django.templatetags.static import static
 12from lxml import etree  # nosec
 13from lxml.etree import Element, SubElement, _Element  # nosec
 14from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout
 15
 16from authentik.lib.utils.dict import get_path_from_dict
 17from authentik.lib.utils.http import get_http_session
 18from authentik.tenants.utils import get_current_tenant
 19
 20if TYPE_CHECKING:
 21    from authentik.core.models import User
 22
 23GRAVATAR_URL = "https://www.gravatar.com"
 24DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
 25AVATAR_STATUS_TTL_SECONDS = 60 * 60 * 8  # 8 Hours
 26
 27SVG_XML_NS = "http://www.w3.org/2000/svg"
 28SVG_NS_MAP = {None: SVG_XML_NS}
 29# Match fonts used in web UI
 30SVG_FONTS = [
 31    "'RedHatText'",
 32    "'Overpass'",
 33    "overpass",
 34    "helvetica",
 35    "arial",
 36    "sans-serif",
 37]
 38
 39
 40def avatar_mode_none(user: User, mode: str) -> str | None:
 41    """No avatar"""
 42    return DEFAULT_AVATAR
 43
 44
 45def avatar_mode_attribute(user: User, mode: str) -> str | None:
 46    """Avatars based on a user attribute"""
 47    avatar = get_path_from_dict(user.attributes, mode[11:], default=None)
 48    return avatar
 49
 50
 51def avatar_mode_gravatar(user: User, mode: str) -> str | None:
 52    """Gravatar avatars"""
 53
 54    mail_hash = sha256(user.email.lower().encode("utf-8")).hexdigest()  # nosec
 55    parameters = {"size": "158", "rating": "g", "default": "404"}
 56    gravatar_url = f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters)}"
 57
 58    return avatar_mode_url(user, gravatar_url)
 59
 60
 61def generate_colors(text: str) -> tuple[str, str]:
 62    """Generate colours based on `text`"""
 63    color = (
 64        int(md5(text.lower().encode("utf-8"), usedforsecurity=False).hexdigest(), 16) % 0xFFFFFF
 65    )  # nosec
 66
 67    # Get a (somewhat arbitrarily) reduced scope of colors
 68    # to avoid too dark or light backgrounds
 69    blue = min(max((color) & 0xFF, 55), 200)
 70    green = min(max((color >> 8) & 0xFF, 55), 200)
 71    red = min(max((color >> 16) & 0xFF, 55), 200)
 72    bg_hex = f"{red:02x}{green:02x}{blue:02x}"
 73    # Contrasting text color (https://stackoverflow.com/a/3943023)
 74    text_hex = (
 75        "000" if (red * 0.299 + green * 0.587 + blue * 0.114) > 186 else "fff"  # noqa: PLR2004
 76    )
 77    return bg_hex, text_hex
 78
 79
 80@funccache
 81def generate_avatar_from_name(
 82    name: str,
 83    length: int = 2,
 84    size: int = 64,
 85    rounded: bool = False,
 86    font_size: float = 0.4375,
 87    bold: bool = False,
 88    uppercase: bool = True,
 89) -> str:
 90    """ "Generate an avatar with initials in SVG format.
 91
 92    Inspired from: https://github.com/LasseRafn/ui-avatars
 93    """
 94    name_parts = name.split()
 95    # Only abbreviate first and last name
 96    if len(name_parts) > 2:  # noqa: PLR2004
 97        name_parts = [name_parts[0], name_parts[-1]]
 98
 99    if len(name_parts) == 1:
100        initials = name_parts[0][:length]
101    else:
102        initials = "".join([part[0] for part in name_parts[:-1]])
103        initials += name_parts[-1]
104        initials = initials[:length]
105
106    bg_hex, text_hex = generate_colors(name)
107
108    half_size = size // 2
109    shape = "circle" if rounded else "rect"
110    font_weight = "600" if bold else "400"
111
112    root_element: _Element = Element(f"{{{SVG_XML_NS}}}svg", nsmap=SVG_NS_MAP)
113    root_element.attrib["width"] = f"{size}px"
114    root_element.attrib["height"] = f"{size}px"
115    root_element.attrib["viewBox"] = f"0 0 {size} {size}"
116    root_element.attrib["version"] = "1.1"
117
118    shape = SubElement(root_element, f"{{{SVG_XML_NS}}}{shape}", nsmap=SVG_NS_MAP)
119    shape.attrib["fill"] = f"#{bg_hex}"
120    shape.attrib["cx"] = f"{half_size}"
121    shape.attrib["cy"] = f"{half_size}"
122    shape.attrib["width"] = f"{size}"
123    shape.attrib["height"] = f"{size}"
124    shape.attrib["r"] = f"{half_size}"
125
126    text = SubElement(root_element, f"{{{SVG_XML_NS}}}text", nsmap=SVG_NS_MAP)
127    text.attrib["x"] = "50%"
128    text.attrib["y"] = "50%"
129    text.attrib["style"] = (
130        f"color: #{text_hex}; " "line-height: 1; " f"font-family: {','.join(SVG_FONTS)}; "
131    )
132    text.attrib["fill"] = f"#{text_hex}"
133    text.attrib["alignment-baseline"] = "middle"
134    text.attrib["dominant-baseline"] = "middle"
135    text.attrib["text-anchor"] = "middle"
136    text.attrib["font-size"] = f"{round(size * font_size)}"
137    text.attrib["font-weight"] = f"{font_weight}"
138    text.attrib["dy"] = ".1em"
139    text.text = initials if not uppercase else initials.upper()
140
141    return etree.tostring(root_element).decode()
142
143
144def avatar_mode_generated(user: User, mode: str) -> str | None:
145    """Wrapper that converts generated avatar to base64 svg"""
146    # By default generate based off of user's display name
147    name = user.name.strip()
148    if name == "":
149        # Fallback to username
150        name = user.username.strip()
151    # If we still don't have anything, fallback to `a k`
152    if name == "":
153        name = "a k"
154    svg = generate_avatar_from_name(name)
155    return f"data:image/svg+xml;base64,{b64encode(svg.encode('utf-8')).decode('utf-8')}"
156
157
158def avatar_mode_url(user: User, mode: str) -> str | None:
159    """Format url"""
160    mail_hash = md5(user.email.lower().encode("utf-8"), usedforsecurity=False).hexdigest()  # nosec
161
162    formatted_url = mode % {
163        "username": user.username,
164        "mail_hash": mail_hash,
165        "upn": user.attributes.get("upn", ""),
166    }
167
168    hostname = urlparse(formatted_url).hostname
169    cache_key_hostname_available = f"goauthentik.io/lib/avatars/{hostname}/available"
170
171    if not cache.get(cache_key_hostname_available, True):
172        return None
173
174    cache_key_image_url = f"goauthentik.io/lib/avatars/{hostname}/{mail_hash}"
175
176    if cache.has_key(cache_key_image_url):
177        cache.touch(cache_key_image_url)
178        return cache.get(cache_key_image_url)
179
180    try:
181        res = get_http_session().head(formatted_url, timeout=5, allow_redirects=True)
182
183        if res.status_code == HttpResponseNotFound.status_code:
184            cache.set(cache_key_image_url, None, timeout=AVATAR_STATUS_TTL_SECONDS)
185            return None
186        if not res.headers.get("Content-Type", "").startswith("image/"):
187            cache.set(cache_key_image_url, None, timeout=AVATAR_STATUS_TTL_SECONDS)
188            return None
189        res.raise_for_status()
190    except Timeout, ConnectionError, HTTPError:
191        cache.set(cache_key_hostname_available, False, timeout=AVATAR_STATUS_TTL_SECONDS)
192        return None
193    except RequestException:
194        return formatted_url
195
196    cache.set(cache_key_image_url, formatted_url, timeout=AVATAR_STATUS_TTL_SECONDS)
197    return formatted_url
198
199
200def get_avatar(user: User, request: HttpRequest | None = None) -> str:
201    """Get avatar with configured mode"""
202    mode_map = {
203        "none": avatar_mode_none,
204        "initials": avatar_mode_generated,
205        "gravatar": avatar_mode_gravatar,
206    }
207    tenant = None
208    if request:
209        tenant = request.tenant
210    else:
211        tenant = get_current_tenant()
212    modes: str = tenant.avatars
213    for mode in modes.split(","):
214        avatar = None
215        if mode in mode_map:
216            avatar = mode_map[mode](user, mode)
217        elif mode.startswith("attributes."):
218            avatar = avatar_mode_attribute(user, mode)
219        elif "://" in mode:
220            avatar = avatar_mode_url(user, mode)
221        if avatar:
222            return avatar
223    return avatar_mode_none(user, modes)
GRAVATAR_URL = 'https://www.gravatar.com'
DEFAULT_AVATAR = '/static/dist/assets/images/user_default.png'
AVATAR_STATUS_TTL_SECONDS = 28800
SVG_XML_NS = 'http://www.w3.org/2000/svg'
SVG_NS_MAP = {None: 'http://www.w3.org/2000/svg'}
SVG_FONTS = ["'RedHatText'", "'Overpass'", 'overpass', 'helvetica', 'arial', 'sans-serif']
def avatar_mode_none(unknown):
41def avatar_mode_none(user: User, mode: str) -> str | None:
42    """No avatar"""
43    return DEFAULT_AVATAR

No avatar

def avatar_mode_attribute(unknown):
46def avatar_mode_attribute(user: User, mode: str) -> str | None:
47    """Avatars based on a user attribute"""
48    avatar = get_path_from_dict(user.attributes, mode[11:], default=None)
49    return avatar

Avatars based on a user attribute

def avatar_mode_gravatar(unknown):
52def avatar_mode_gravatar(user: User, mode: str) -> str | None:
53    """Gravatar avatars"""
54
55    mail_hash = sha256(user.email.lower().encode("utf-8")).hexdigest()  # nosec
56    parameters = {"size": "158", "rating": "g", "default": "404"}
57    gravatar_url = f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters)}"
58
59    return avatar_mode_url(user, gravatar_url)

Gravatar avatars

def generate_colors(text: str) -> tuple[str, str]:
62def generate_colors(text: str) -> tuple[str, str]:
63    """Generate colours based on `text`"""
64    color = (
65        int(md5(text.lower().encode("utf-8"), usedforsecurity=False).hexdigest(), 16) % 0xFFFFFF
66    )  # nosec
67
68    # Get a (somewhat arbitrarily) reduced scope of colors
69    # to avoid too dark or light backgrounds
70    blue = min(max((color) & 0xFF, 55), 200)
71    green = min(max((color >> 8) & 0xFF, 55), 200)
72    red = min(max((color >> 16) & 0xFF, 55), 200)
73    bg_hex = f"{red:02x}{green:02x}{blue:02x}"
74    # Contrasting text color (https://stackoverflow.com/a/3943023)
75    text_hex = (
76        "000" if (red * 0.299 + green * 0.587 + blue * 0.114) > 186 else "fff"  # noqa: PLR2004
77    )
78    return bg_hex, text_hex

Generate colours based on text

@funccache
def generate_avatar_from_name( name: str, length: int = 2, size: int = 64, rounded: bool = False, font_size: float = 0.4375, bold: bool = False, uppercase: bool = True) -> str:
 81@funccache
 82def generate_avatar_from_name(
 83    name: str,
 84    length: int = 2,
 85    size: int = 64,
 86    rounded: bool = False,
 87    font_size: float = 0.4375,
 88    bold: bool = False,
 89    uppercase: bool = True,
 90) -> str:
 91    """ "Generate an avatar with initials in SVG format.
 92
 93    Inspired from: https://github.com/LasseRafn/ui-avatars
 94    """
 95    name_parts = name.split()
 96    # Only abbreviate first and last name
 97    if len(name_parts) > 2:  # noqa: PLR2004
 98        name_parts = [name_parts[0], name_parts[-1]]
 99
100    if len(name_parts) == 1:
101        initials = name_parts[0][:length]
102    else:
103        initials = "".join([part[0] for part in name_parts[:-1]])
104        initials += name_parts[-1]
105        initials = initials[:length]
106
107    bg_hex, text_hex = generate_colors(name)
108
109    half_size = size // 2
110    shape = "circle" if rounded else "rect"
111    font_weight = "600" if bold else "400"
112
113    root_element: _Element = Element(f"{{{SVG_XML_NS}}}svg", nsmap=SVG_NS_MAP)
114    root_element.attrib["width"] = f"{size}px"
115    root_element.attrib["height"] = f"{size}px"
116    root_element.attrib["viewBox"] = f"0 0 {size} {size}"
117    root_element.attrib["version"] = "1.1"
118
119    shape = SubElement(root_element, f"{{{SVG_XML_NS}}}{shape}", nsmap=SVG_NS_MAP)
120    shape.attrib["fill"] = f"#{bg_hex}"
121    shape.attrib["cx"] = f"{half_size}"
122    shape.attrib["cy"] = f"{half_size}"
123    shape.attrib["width"] = f"{size}"
124    shape.attrib["height"] = f"{size}"
125    shape.attrib["r"] = f"{half_size}"
126
127    text = SubElement(root_element, f"{{{SVG_XML_NS}}}text", nsmap=SVG_NS_MAP)
128    text.attrib["x"] = "50%"
129    text.attrib["y"] = "50%"
130    text.attrib["style"] = (
131        f"color: #{text_hex}; " "line-height: 1; " f"font-family: {','.join(SVG_FONTS)}; "
132    )
133    text.attrib["fill"] = f"#{text_hex}"
134    text.attrib["alignment-baseline"] = "middle"
135    text.attrib["dominant-baseline"] = "middle"
136    text.attrib["text-anchor"] = "middle"
137    text.attrib["font-size"] = f"{round(size * font_size)}"
138    text.attrib["font-weight"] = f"{font_weight}"
139    text.attrib["dy"] = ".1em"
140    text.text = initials if not uppercase else initials.upper()
141
142    return etree.tostring(root_element).decode()

"Generate an avatar with initials in SVG format.

Inspired from: https://github.com/LasseRafn/ui-avatars

def avatar_mode_generated(unknown):
145def avatar_mode_generated(user: User, mode: str) -> str | None:
146    """Wrapper that converts generated avatar to base64 svg"""
147    # By default generate based off of user's display name
148    name = user.name.strip()
149    if name == "":
150        # Fallback to username
151        name = user.username.strip()
152    # If we still don't have anything, fallback to `a k`
153    if name == "":
154        name = "a k"
155    svg = generate_avatar_from_name(name)
156    return f"data:image/svg+xml;base64,{b64encode(svg.encode('utf-8')).decode('utf-8')}"

Wrapper that converts generated avatar to base64 svg

def avatar_mode_url(unknown):
159def avatar_mode_url(user: User, mode: str) -> str | None:
160    """Format url"""
161    mail_hash = md5(user.email.lower().encode("utf-8"), usedforsecurity=False).hexdigest()  # nosec
162
163    formatted_url = mode % {
164        "username": user.username,
165        "mail_hash": mail_hash,
166        "upn": user.attributes.get("upn", ""),
167    }
168
169    hostname = urlparse(formatted_url).hostname
170    cache_key_hostname_available = f"goauthentik.io/lib/avatars/{hostname}/available"
171
172    if not cache.get(cache_key_hostname_available, True):
173        return None
174
175    cache_key_image_url = f"goauthentik.io/lib/avatars/{hostname}/{mail_hash}"
176
177    if cache.has_key(cache_key_image_url):
178        cache.touch(cache_key_image_url)
179        return cache.get(cache_key_image_url)
180
181    try:
182        res = get_http_session().head(formatted_url, timeout=5, allow_redirects=True)
183
184        if res.status_code == HttpResponseNotFound.status_code:
185            cache.set(cache_key_image_url, None, timeout=AVATAR_STATUS_TTL_SECONDS)
186            return None
187        if not res.headers.get("Content-Type", "").startswith("image/"):
188            cache.set(cache_key_image_url, None, timeout=AVATAR_STATUS_TTL_SECONDS)
189            return None
190        res.raise_for_status()
191    except Timeout, ConnectionError, HTTPError:
192        cache.set(cache_key_hostname_available, False, timeout=AVATAR_STATUS_TTL_SECONDS)
193        return None
194    except RequestException:
195        return formatted_url
196
197    cache.set(cache_key_image_url, formatted_url, timeout=AVATAR_STATUS_TTL_SECONDS)
198    return formatted_url

Format url

def get_avatar(unknown):
201def get_avatar(user: User, request: HttpRequest | None = None) -> str:
202    """Get avatar with configured mode"""
203    mode_map = {
204        "none": avatar_mode_none,
205        "initials": avatar_mode_generated,
206        "gravatar": avatar_mode_gravatar,
207    }
208    tenant = None
209    if request:
210        tenant = request.tenant
211    else:
212        tenant = get_current_tenant()
213    modes: str = tenant.avatars
214    for mode in modes.split(","):
215        avatar = None
216        if mode in mode_map:
217            avatar = mode_map[mode](user, mode)
218        elif mode.startswith("attributes."):
219            avatar = avatar_mode_attribute(user, mode)
220        elif "://" in mode:
221            avatar = avatar_mode_url(user, mode)
222        if avatar:
223            return avatar
224    return avatar_mode_none(user, modes)

Get avatar with configured mode