Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 09de2ae

Browse files
authored
Add support for handling avatar with SSO login (#13917)
This commit adds support for handling a provided avatar picture URL when logging in via SSO. Signed-off-by: Ashish Kumar <[email protected]> Fixes #9357.
1 parent 39cde58 commit 09de2ae

File tree

6 files changed

+275
-2
lines changed

6 files changed

+275
-2
lines changed

changelog.d/13917.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Adds support for handling avatar in SSO login. Contributed by @ashfame.

docs/usage/configuration/config_documentation.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2968,10 +2968,17 @@ Options for each entry include:
29682968

29692969
For the default provider, the following settings are available:
29702970

2971-
* subject_claim: name of the claim containing a unique identifier
2971+
* `subject_claim`: name of the claim containing a unique identifier
29722972
for the user. Defaults to 'sub', which OpenID Connect
29732973
compliant providers should provide.
29742974

2975+
* `picture_claim`: name of the claim containing an url for the user's profile picture.
2976+
Defaults to 'picture', which OpenID Connect compliant providers should provide
2977+
and has to refer to a direct image file such as PNG, JPEG, or GIF image file.
2978+
2979+
Currently only supported in monolithic (single-process) server configurations
2980+
where the media repository runs within the Synapse process.
2981+
29752982
* `localpart_template`: Jinja2 template for the localpart of the MXID.
29762983
If this is not set, the user will be prompted to choose their
29772984
own username (see the documentation for the `sso_auth_account_details.html`

mypy.ini

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ disallow_untyped_defs = True
119119
[mypy-tests.storage.test_profile]
120120
disallow_untyped_defs = True
121121

122+
[mypy-tests.handlers.test_sso]
123+
disallow_untyped_defs = True
124+
122125
[mypy-tests.storage.test_user_directory]
123126
disallow_untyped_defs = True
124127

@@ -137,7 +140,6 @@ disallow_untyped_defs = False
137140
[mypy-tests.utils]
138141
disallow_untyped_defs = True
139142

140-
141143
;; Dependencies without annotations
142144
;; Before ignoring a module, check to see if type stubs are available.
143145
;; The `typeshed` project maintains stubs here:

synapse/handlers/oidc.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,7 @@ class UserAttributeDict(TypedDict):
14351435
localpart: Optional[str]
14361436
confirm_localpart: bool
14371437
display_name: Optional[str]
1438+
picture: Optional[str] # may be omitted by older `OidcMappingProviders`
14381439
emails: List[str]
14391440

14401441

@@ -1520,6 +1521,7 @@ def jinja_finalize(thing: Any) -> Any:
15201521
@attr.s(slots=True, frozen=True, auto_attribs=True)
15211522
class JinjaOidcMappingConfig:
15221523
subject_claim: str
1524+
picture_claim: str
15231525
localpart_template: Optional[Template]
15241526
display_name_template: Optional[Template]
15251527
email_template: Optional[Template]
@@ -1539,6 +1541,7 @@ def __init__(self, config: JinjaOidcMappingConfig):
15391541
@staticmethod
15401542
def parse_config(config: dict) -> JinjaOidcMappingConfig:
15411543
subject_claim = config.get("subject_claim", "sub")
1544+
picture_claim = config.get("picture_claim", "picture")
15421545

15431546
def parse_template_config(option_name: str) -> Optional[Template]:
15441547
if option_name not in config:
@@ -1572,6 +1575,7 @@ def parse_template_config(option_name: str) -> Optional[Template]:
15721575

15731576
return JinjaOidcMappingConfig(
15741577
subject_claim=subject_claim,
1578+
picture_claim=picture_claim,
15751579
localpart_template=localpart_template,
15761580
display_name_template=display_name_template,
15771581
email_template=email_template,
@@ -1611,10 +1615,13 @@ def render_template_field(template: Optional[Template]) -> Optional[str]:
16111615
if email:
16121616
emails.append(email)
16131617

1618+
picture = userinfo.get("picture")
1619+
16141620
return UserAttributeDict(
16151621
localpart=localpart,
16161622
display_name=display_name,
16171623
emails=emails,
1624+
picture=picture,
16181625
confirm_localpart=self._config.confirm_localpart,
16191626
)
16201627

synapse/handlers/sso.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import abc
15+
import hashlib
16+
import io
1517
import logging
1618
from typing import (
1719
TYPE_CHECKING,
@@ -138,6 +140,7 @@ class UserAttributes:
138140
localpart: Optional[str]
139141
confirm_localpart: bool = False
140142
display_name: Optional[str] = None
143+
picture: Optional[str] = None
141144
emails: Collection[str] = attr.Factory(list)
142145

143146

@@ -196,6 +199,10 @@ def __init__(self, hs: "HomeServer"):
196199
self._error_template = hs.config.sso.sso_error_template
197200
self._bad_user_template = hs.config.sso.sso_auth_bad_user_template
198201
self._profile_handler = hs.get_profile_handler()
202+
self._media_repo = (
203+
hs.get_media_repository() if hs.config.media.can_load_media_repo else None
204+
)
205+
self._http_client = hs.get_proxied_blacklisted_http_client()
199206

200207
# The following template is shown after a successful user interactive
201208
# authentication session. It tells the user they can close the window.
@@ -495,6 +502,8 @@ async def complete_sso_login_request(
495502
await self._profile_handler.set_displayname(
496503
user_id_obj, requester, attributes.display_name, True
497504
)
505+
if attributes.picture:
506+
await self.set_avatar(user_id, attributes.picture)
498507

499508
await self._auth_handler.complete_sso_login(
500509
user_id,
@@ -703,8 +712,110 @@ async def _register_mapped_user(
703712
await self._store.record_user_external_id(
704713
auth_provider_id, remote_user_id, registered_user_id
705714
)
715+
716+
# Set avatar, if available
717+
if attributes.picture:
718+
await self.set_avatar(registered_user_id, attributes.picture)
719+
706720
return registered_user_id
707721

722+
async def set_avatar(self, user_id: str, picture_https_url: str) -> bool:
723+
"""Set avatar of the user.
724+
725+
This downloads the image file from the URL provided, stores that in
726+
the media repository and then sets the avatar on the user's profile.
727+
728+
It can detect if the same image is being saved again and bails early by storing
729+
the hash of the file in the `upload_name` of the avatar image.
730+
731+
Currently, it only supports server configurations which run the media repository
732+
within the same process.
733+
734+
It silently fails and logs a warning by raising an exception and catching it
735+
internally if:
736+
* it is unable to fetch the image itself (non 200 status code) or
737+
* the image supplied is bigger than max allowed size or
738+
* the image type is not one of the allowed image types.
739+
740+
Args:
741+
user_id: matrix user ID in the form @localpart:domain as a string.
742+
743+
picture_https_url: HTTPS url for the picture image file.
744+
745+
Returns: `True` if the user's avatar has been successfully set to the image at
746+
`picture_https_url`.
747+
"""
748+
if self._media_repo is None:
749+
logger.info(
750+
"failed to set user avatar because out-of-process media repositories "
751+
"are not supported yet "
752+
)
753+
return False
754+
755+
try:
756+
uid = UserID.from_string(user_id)
757+
758+
def is_allowed_mime_type(content_type: str) -> bool:
759+
if (
760+
self._profile_handler.allowed_avatar_mimetypes
761+
and content_type
762+
not in self._profile_handler.allowed_avatar_mimetypes
763+
):
764+
return False
765+
return True
766+
767+
# download picture, enforcing size limit & mime type check
768+
picture = io.BytesIO()
769+
770+
content_length, headers, uri, code = await self._http_client.get_file(
771+
url=picture_https_url,
772+
output_stream=picture,
773+
max_size=self._profile_handler.max_avatar_size,
774+
is_allowed_content_type=is_allowed_mime_type,
775+
)
776+
777+
if code != 200:
778+
raise Exception(
779+
"GET request to download sso avatar image returned {}".format(code)
780+
)
781+
782+
# upload name includes hash of the image file's content so that we can
783+
# easily check if it requires an update or not, the next time user logs in
784+
upload_name = "sso_avatar_" + hashlib.sha256(picture.read()).hexdigest()
785+
786+
# bail if user already has the same avatar
787+
profile = await self._profile_handler.get_profile(user_id)
788+
if profile["avatar_url"] is not None:
789+
server_name = profile["avatar_url"].split("/")[-2]
790+
media_id = profile["avatar_url"].split("/")[-1]
791+
if server_name == self._server_name:
792+
media = await self._media_repo.store.get_local_media(media_id)
793+
if media is not None and upload_name == media["upload_name"]:
794+
logger.info("skipping saving the user avatar")
795+
return True
796+
797+
# store it in media repository
798+
avatar_mxc_url = await self._media_repo.create_content(
799+
media_type=headers[b"Content-Type"][0].decode("utf-8"),
800+
upload_name=upload_name,
801+
content=picture,
802+
content_length=content_length,
803+
auth_user=uid,
804+
)
805+
806+
# save it as user avatar
807+
await self._profile_handler.set_avatar_url(
808+
uid,
809+
create_requester(uid),
810+
str(avatar_mxc_url),
811+
)
812+
813+
logger.info("successfully saved the user avatar")
814+
return True
815+
except Exception:
816+
logger.warning("failed to save the user avatar")
817+
return False
818+
708819
async def complete_sso_ui_auth_request(
709820
self,
710821
auth_provider_id: str,

tests/handlers/test_sso.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
from http import HTTPStatus
13+
from typing import BinaryIO, Callable, Dict, List, Optional, Tuple
14+
from unittest.mock import Mock
15+
16+
from twisted.test.proto_helpers import MemoryReactor
17+
from twisted.web.http_headers import Headers
18+
19+
from synapse.api.errors import Codes, SynapseError
20+
from synapse.http.client import RawHeaders
21+
from synapse.server import HomeServer
22+
from synapse.util import Clock
23+
24+
from tests import unittest
25+
from tests.test_utils import SMALL_PNG, FakeResponse
26+
27+
28+
class TestSSOHandler(unittest.HomeserverTestCase):
29+
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
30+
self.http_client = Mock(spec=["get_file"])
31+
self.http_client.get_file.side_effect = mock_get_file
32+
self.http_client.user_agent = b"Synapse Test"
33+
hs = self.setup_test_homeserver(
34+
proxied_blacklisted_http_client=self.http_client
35+
)
36+
return hs
37+
38+
async def test_set_avatar(self) -> None:
39+
"""Tests successfully setting the avatar of a newly created user"""
40+
handler = self.hs.get_sso_handler()
41+
42+
# Create a new user to set avatar for
43+
reg_handler = self.hs.get_registration_handler()
44+
user_id = self.get_success(reg_handler.register_user(approved=True))
45+
46+
self.assertTrue(
47+
self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
48+
)
49+
50+
# Ensure avatar is set on this newly created user,
51+
# so no need to compare for the exact image
52+
profile_handler = self.hs.get_profile_handler()
53+
profile = self.get_success(profile_handler.get_profile(user_id))
54+
self.assertIsNot(profile["avatar_url"], None)
55+
56+
@unittest.override_config({"max_avatar_size": 1})
57+
async def test_set_avatar_too_big_image(self) -> None:
58+
"""Tests that saving an avatar fails when it is too big"""
59+
handler = self.hs.get_sso_handler()
60+
61+
# any random user works since image check is supposed to fail
62+
user_id = "@sso-user:test"
63+
64+
self.assertFalse(
65+
self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
66+
)
67+
68+
@unittest.override_config({"allowed_avatar_mimetypes": ["image/jpeg"]})
69+
async def test_set_avatar_incorrect_mime_type(self) -> None:
70+
"""Tests that saving an avatar fails when its mime type is not allowed"""
71+
handler = self.hs.get_sso_handler()
72+
73+
# any random user works since image check is supposed to fail
74+
user_id = "@sso-user:test"
75+
76+
self.assertFalse(
77+
self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
78+
)
79+
80+
async def test_skip_saving_avatar_when_not_changed(self) -> None:
81+
"""Tests whether saving of avatar correctly skips if the avatar hasn't
82+
changed"""
83+
handler = self.hs.get_sso_handler()
84+
85+
# Create a new user to set avatar for
86+
reg_handler = self.hs.get_registration_handler()
87+
user_id = self.get_success(reg_handler.register_user(approved=True))
88+
89+
# set avatar for the first time, should be a success
90+
self.assertTrue(
91+
self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
92+
)
93+
94+
# get avatar picture for comparison after another attempt
95+
profile_handler = self.hs.get_profile_handler()
96+
profile = self.get_success(profile_handler.get_profile(user_id))
97+
url_to_match = profile["avatar_url"]
98+
99+
# set same avatar for the second time, should be a success
100+
self.assertTrue(
101+
self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
102+
)
103+
104+
# compare avatar picture's url from previous step
105+
profile = self.get_success(profile_handler.get_profile(user_id))
106+
self.assertEqual(profile["avatar_url"], url_to_match)
107+
108+
109+
async def mock_get_file(
110+
url: str,
111+
output_stream: BinaryIO,
112+
max_size: Optional[int] = None,
113+
headers: Optional[RawHeaders] = None,
114+
is_allowed_content_type: Optional[Callable[[str], bool]] = None,
115+
) -> Tuple[int, Dict[bytes, List[bytes]], str, int]:
116+
117+
fake_response = FakeResponse(code=404)
118+
if url == "http://my.server/me.png":
119+
fake_response = FakeResponse(
120+
code=200,
121+
headers=Headers(
122+
{"Content-Type": ["image/png"], "Content-Length": [str(len(SMALL_PNG))]}
123+
),
124+
body=SMALL_PNG,
125+
)
126+
127+
if max_size is not None and max_size < len(SMALL_PNG):
128+
raise SynapseError(
129+
HTTPStatus.BAD_GATEWAY,
130+
"Requested file is too large > %r bytes" % (max_size,),
131+
Codes.TOO_LARGE,
132+
)
133+
134+
if is_allowed_content_type and not is_allowed_content_type("image/png"):
135+
raise SynapseError(
136+
HTTPStatus.BAD_GATEWAY,
137+
(
138+
"Requested file's content type not allowed for this operation: %s"
139+
% "image/png"
140+
),
141+
)
142+
143+
output_stream.write(fake_response.body)
144+
145+
return len(SMALL_PNG), {b"Content-Type": [b"image/png"]}, "", 200

0 commit comments

Comments
 (0)