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