forked from MirrorHub/synapse
Support UI Authentication for OpenID Connect accounts (#7457)
This commit is contained in:
parent
03aff4c75e
commit
a3cf36f76e
6 changed files with 105 additions and 41 deletions
1
changelog.d/7457.feature
Normal file
1
changelog.d/7457.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add OpenID Connect login/registration support. Contributed by Quentin Gliech, on behalf of [les Connecteurs](https://connecteu.rs).
|
|
@ -80,7 +80,9 @@ class AuthHandler(BaseHandler):
|
||||||
self.hs = hs # FIXME better possibility to access registrationHandler later?
|
self.hs = hs # FIXME better possibility to access registrationHandler later?
|
||||||
self.macaroon_gen = hs.get_macaroon_generator()
|
self.macaroon_gen = hs.get_macaroon_generator()
|
||||||
self._password_enabled = hs.config.password_enabled
|
self._password_enabled = hs.config.password_enabled
|
||||||
self._sso_enabled = hs.config.saml2_enabled or hs.config.cas_enabled
|
self._sso_enabled = (
|
||||||
|
hs.config.cas_enabled or hs.config.saml2_enabled or hs.config.oidc_enabled
|
||||||
|
)
|
||||||
|
|
||||||
# we keep this as a list despite the O(N^2) implication so that we can
|
# we keep this as a list despite the O(N^2) implication so that we can
|
||||||
# keep PASSWORD first and avoid confusing clients which pick the first
|
# keep PASSWORD first and avoid confusing clients which pick the first
|
||||||
|
|
|
@ -311,7 +311,7 @@ class OidcHandler:
|
||||||
``ClientAuth`` to authenticate with the client with its ID and secret.
|
``ClientAuth`` to authenticate with the client with its ID and secret.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
code: The autorization code we got from the callback.
|
code: The authorization code we got from the callback.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dict containing various tokens.
|
A dict containing various tokens.
|
||||||
|
@ -497,11 +497,14 @@ class OidcHandler:
|
||||||
return UserInfo(claims)
|
return UserInfo(claims)
|
||||||
|
|
||||||
async def handle_redirect_request(
|
async def handle_redirect_request(
|
||||||
self, request: SynapseRequest, client_redirect_url: bytes
|
self,
|
||||||
) -> None:
|
request: SynapseRequest,
|
||||||
|
client_redirect_url: bytes,
|
||||||
|
ui_auth_session_id: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
"""Handle an incoming request to /login/sso/redirect
|
"""Handle an incoming request to /login/sso/redirect
|
||||||
|
|
||||||
It redirects the browser to the authorization endpoint with a few
|
It returns a redirect to the authorization endpoint with a few
|
||||||
parameters:
|
parameters:
|
||||||
|
|
||||||
- ``client_id``: the client ID set in ``oidc_config.client_id``
|
- ``client_id``: the client ID set in ``oidc_config.client_id``
|
||||||
|
@ -511,24 +514,32 @@ class OidcHandler:
|
||||||
- ``state``: a random string
|
- ``state``: a random string
|
||||||
- ``nonce``: a random string
|
- ``nonce``: a random string
|
||||||
|
|
||||||
In addition to redirecting the client, we are setting a cookie with
|
In addition generating a redirect URL, we are setting a cookie with
|
||||||
a signed macaroon token containing the state, the nonce and the
|
a signed macaroon token containing the state, the nonce and the
|
||||||
client_redirect_url params. Those are then checked when the client
|
client_redirect_url params. Those are then checked when the client
|
||||||
comes back from the provider.
|
comes back from the provider.
|
||||||
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: the incoming request from the browser.
|
request: the incoming request from the browser.
|
||||||
We'll respond to it with a redirect and a cookie.
|
We'll respond to it with a redirect and a cookie.
|
||||||
client_redirect_url: the URL that we should redirect the client to
|
client_redirect_url: the URL that we should redirect the client to
|
||||||
when everything is done
|
when everything is done
|
||||||
|
ui_auth_session_id: The session ID of the ongoing UI Auth (or
|
||||||
|
None if this is a login).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The redirect URL to the authorization endpoint.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
state = generate_token()
|
state = generate_token()
|
||||||
nonce = generate_token()
|
nonce = generate_token()
|
||||||
|
|
||||||
cookie = self._generate_oidc_session_token(
|
cookie = self._generate_oidc_session_token(
|
||||||
state=state, nonce=nonce, client_redirect_url=client_redirect_url.decode(),
|
state=state,
|
||||||
|
nonce=nonce,
|
||||||
|
client_redirect_url=client_redirect_url.decode(),
|
||||||
|
ui_auth_session_id=ui_auth_session_id,
|
||||||
)
|
)
|
||||||
request.addCookie(
|
request.addCookie(
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
|
@ -541,7 +552,7 @@ class OidcHandler:
|
||||||
|
|
||||||
metadata = await self.load_metadata()
|
metadata = await self.load_metadata()
|
||||||
authorization_endpoint = metadata.get("authorization_endpoint")
|
authorization_endpoint = metadata.get("authorization_endpoint")
|
||||||
uri = prepare_grant_uri(
|
return prepare_grant_uri(
|
||||||
authorization_endpoint,
|
authorization_endpoint,
|
||||||
client_id=self._client_auth.client_id,
|
client_id=self._client_auth.client_id,
|
||||||
response_type="code",
|
response_type="code",
|
||||||
|
@ -550,8 +561,6 @@ class OidcHandler:
|
||||||
state=state,
|
state=state,
|
||||||
nonce=nonce,
|
nonce=nonce,
|
||||||
)
|
)
|
||||||
request.redirect(uri)
|
|
||||||
finish_request(request)
|
|
||||||
|
|
||||||
async def handle_oidc_callback(self, request: SynapseRequest) -> None:
|
async def handle_oidc_callback(self, request: SynapseRequest) -> None:
|
||||||
"""Handle an incoming request to /_synapse/oidc/callback
|
"""Handle an incoming request to /_synapse/oidc/callback
|
||||||
|
@ -625,7 +634,11 @@ class OidcHandler:
|
||||||
|
|
||||||
# Deserialize the session token and verify it.
|
# Deserialize the session token and verify it.
|
||||||
try:
|
try:
|
||||||
nonce, client_redirect_url = self._verify_oidc_session_token(session, state)
|
(
|
||||||
|
nonce,
|
||||||
|
client_redirect_url,
|
||||||
|
ui_auth_session_id,
|
||||||
|
) = self._verify_oidc_session_token(session, state)
|
||||||
except MacaroonDeserializationException as e:
|
except MacaroonDeserializationException as e:
|
||||||
logger.exception("Invalid session")
|
logger.exception("Invalid session")
|
||||||
self._render_error(request, "invalid_session", str(e))
|
self._render_error(request, "invalid_session", str(e))
|
||||||
|
@ -678,6 +691,11 @@ class OidcHandler:
|
||||||
return
|
return
|
||||||
|
|
||||||
# and finally complete the login
|
# and finally complete the login
|
||||||
|
if ui_auth_session_id:
|
||||||
|
await self._auth_handler.complete_sso_ui_auth(
|
||||||
|
user_id, ui_auth_session_id, request
|
||||||
|
)
|
||||||
|
else:
|
||||||
await self._auth_handler.complete_sso_login(
|
await self._auth_handler.complete_sso_login(
|
||||||
user_id, request, client_redirect_url
|
user_id, request, client_redirect_url
|
||||||
)
|
)
|
||||||
|
@ -687,6 +705,7 @@ class OidcHandler:
|
||||||
state: str,
|
state: str,
|
||||||
nonce: str,
|
nonce: str,
|
||||||
client_redirect_url: str,
|
client_redirect_url: str,
|
||||||
|
ui_auth_session_id: Optional[str],
|
||||||
duration_in_ms: int = (60 * 60 * 1000),
|
duration_in_ms: int = (60 * 60 * 1000),
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generates a signed token storing data about an OIDC session.
|
"""Generates a signed token storing data about an OIDC session.
|
||||||
|
@ -702,6 +721,8 @@ class OidcHandler:
|
||||||
nonce: The ``nonce`` parameter passed to the OIDC provider.
|
nonce: The ``nonce`` parameter passed to the OIDC provider.
|
||||||
client_redirect_url: The URL the client gave when it initiated the
|
client_redirect_url: The URL the client gave when it initiated the
|
||||||
flow.
|
flow.
|
||||||
|
ui_auth_session_id: The session ID of the ongoing UI Auth (or
|
||||||
|
None if this is a login).
|
||||||
duration_in_ms: An optional duration for the token in milliseconds.
|
duration_in_ms: An optional duration for the token in milliseconds.
|
||||||
Defaults to an hour.
|
Defaults to an hour.
|
||||||
|
|
||||||
|
@ -718,12 +739,19 @@ class OidcHandler:
|
||||||
macaroon.add_first_party_caveat(
|
macaroon.add_first_party_caveat(
|
||||||
"client_redirect_url = %s" % (client_redirect_url,)
|
"client_redirect_url = %s" % (client_redirect_url,)
|
||||||
)
|
)
|
||||||
|
if ui_auth_session_id:
|
||||||
|
macaroon.add_first_party_caveat(
|
||||||
|
"ui_auth_session_id = %s" % (ui_auth_session_id,)
|
||||||
|
)
|
||||||
now = self._clock.time_msec()
|
now = self._clock.time_msec()
|
||||||
expiry = now + duration_in_ms
|
expiry = now + duration_in_ms
|
||||||
macaroon.add_first_party_caveat("time < %d" % (expiry,))
|
macaroon.add_first_party_caveat("time < %d" % (expiry,))
|
||||||
|
|
||||||
return macaroon.serialize()
|
return macaroon.serialize()
|
||||||
|
|
||||||
def _verify_oidc_session_token(self, session: str, state: str) -> Tuple[str, str]:
|
def _verify_oidc_session_token(
|
||||||
|
self, session: str, state: str
|
||||||
|
) -> Tuple[str, str, Optional[str]]:
|
||||||
"""Verifies and extract an OIDC session token.
|
"""Verifies and extract an OIDC session token.
|
||||||
|
|
||||||
This verifies that a given session token was issued by this homeserver
|
This verifies that a given session token was issued by this homeserver
|
||||||
|
@ -734,7 +762,7 @@ class OidcHandler:
|
||||||
state: The state the OIDC provider gave back
|
state: The state the OIDC provider gave back
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The nonce and the client_redirect_url for this session
|
The nonce, client_redirect_url, and ui_auth_session_id for this session
|
||||||
"""
|
"""
|
||||||
macaroon = pymacaroons.Macaroon.deserialize(session)
|
macaroon = pymacaroons.Macaroon.deserialize(session)
|
||||||
|
|
||||||
|
@ -744,17 +772,27 @@ class OidcHandler:
|
||||||
v.satisfy_exact("state = %s" % (state,))
|
v.satisfy_exact("state = %s" % (state,))
|
||||||
v.satisfy_general(lambda c: c.startswith("nonce = "))
|
v.satisfy_general(lambda c: c.startswith("nonce = "))
|
||||||
v.satisfy_general(lambda c: c.startswith("client_redirect_url = "))
|
v.satisfy_general(lambda c: c.startswith("client_redirect_url = "))
|
||||||
|
# Sometimes there's a UI auth session ID, it seems to be OK to attempt
|
||||||
|
# to always satisfy this.
|
||||||
|
v.satisfy_general(lambda c: c.startswith("ui_auth_session_id = "))
|
||||||
v.satisfy_general(self._verify_expiry)
|
v.satisfy_general(self._verify_expiry)
|
||||||
|
|
||||||
v.verify(macaroon, self._macaroon_secret_key)
|
v.verify(macaroon, self._macaroon_secret_key)
|
||||||
|
|
||||||
# Extract the `nonce` and `client_redirect_url` from the token
|
# Extract the `nonce`, `client_redirect_url`, and maybe the
|
||||||
|
# `ui_auth_session_id` from the token.
|
||||||
nonce = self._get_value_from_macaroon(macaroon, "nonce")
|
nonce = self._get_value_from_macaroon(macaroon, "nonce")
|
||||||
client_redirect_url = self._get_value_from_macaroon(
|
client_redirect_url = self._get_value_from_macaroon(
|
||||||
macaroon, "client_redirect_url"
|
macaroon, "client_redirect_url"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
ui_auth_session_id = self._get_value_from_macaroon(
|
||||||
|
macaroon, "ui_auth_session_id"
|
||||||
|
) # type: Optional[str]
|
||||||
|
except ValueError:
|
||||||
|
ui_auth_session_id = None
|
||||||
|
|
||||||
return nonce, client_redirect_url
|
return nonce, client_redirect_url, ui_auth_session_id
|
||||||
|
|
||||||
def _get_value_from_macaroon(self, macaroon: pymacaroons.Macaroon, key: str) -> str:
|
def _get_value_from_macaroon(self, macaroon: pymacaroons.Macaroon, key: str) -> str:
|
||||||
"""Extracts a caveat value from a macaroon token.
|
"""Extracts a caveat value from a macaroon token.
|
||||||
|
@ -773,7 +811,7 @@ class OidcHandler:
|
||||||
for caveat in macaroon.caveats:
|
for caveat in macaroon.caveats:
|
||||||
if caveat.caveat_id.startswith(prefix):
|
if caveat.caveat_id.startswith(prefix):
|
||||||
return caveat.caveat_id[len(prefix) :]
|
return caveat.caveat_id[len(prefix) :]
|
||||||
raise Exception("No %s caveat in macaroon" % (key,))
|
raise ValueError("No %s caveat in macaroon" % (key,))
|
||||||
|
|
||||||
def _verify_expiry(self, caveat: str) -> bool:
|
def _verify_expiry(self, caveat: str) -> bool:
|
||||||
prefix = "time < "
|
prefix = "time < "
|
||||||
|
|
|
@ -401,19 +401,22 @@ class BaseSSORedirectServlet(RestServlet):
|
||||||
|
|
||||||
PATTERNS = client_patterns("/login/(cas|sso)/redirect", v1=True)
|
PATTERNS = client_patterns("/login/(cas|sso)/redirect", v1=True)
|
||||||
|
|
||||||
def on_GET(self, request: SynapseRequest):
|
async def on_GET(self, request: SynapseRequest):
|
||||||
args = request.args
|
args = request.args
|
||||||
if b"redirectUrl" not in args:
|
if b"redirectUrl" not in args:
|
||||||
return 400, "Redirect URL not specified for SSO auth"
|
return 400, "Redirect URL not specified for SSO auth"
|
||||||
client_redirect_url = args[b"redirectUrl"][0]
|
client_redirect_url = args[b"redirectUrl"][0]
|
||||||
sso_url = self.get_sso_url(client_redirect_url)
|
sso_url = await self.get_sso_url(request, client_redirect_url)
|
||||||
request.redirect(sso_url)
|
request.redirect(sso_url)
|
||||||
finish_request(request)
|
finish_request(request)
|
||||||
|
|
||||||
def get_sso_url(self, client_redirect_url: bytes) -> bytes:
|
async def get_sso_url(
|
||||||
|
self, request: SynapseRequest, client_redirect_url: bytes
|
||||||
|
) -> bytes:
|
||||||
"""Get the URL to redirect to, to perform SSO auth
|
"""Get the URL to redirect to, to perform SSO auth
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
request: The client request to redirect.
|
||||||
client_redirect_url: the URL that we should redirect the
|
client_redirect_url: the URL that we should redirect the
|
||||||
client to when everything is done
|
client to when everything is done
|
||||||
|
|
||||||
|
@ -428,7 +431,9 @@ class CasRedirectServlet(BaseSSORedirectServlet):
|
||||||
def __init__(self, hs):
|
def __init__(self, hs):
|
||||||
self._cas_handler = hs.get_cas_handler()
|
self._cas_handler = hs.get_cas_handler()
|
||||||
|
|
||||||
def get_sso_url(self, client_redirect_url: bytes) -> bytes:
|
async def get_sso_url(
|
||||||
|
self, request: SynapseRequest, client_redirect_url: bytes
|
||||||
|
) -> bytes:
|
||||||
return self._cas_handler.get_redirect_url(
|
return self._cas_handler.get_redirect_url(
|
||||||
{"redirectUrl": client_redirect_url}
|
{"redirectUrl": client_redirect_url}
|
||||||
).encode("ascii")
|
).encode("ascii")
|
||||||
|
@ -465,11 +470,13 @@ class SAMLRedirectServlet(BaseSSORedirectServlet):
|
||||||
def __init__(self, hs):
|
def __init__(self, hs):
|
||||||
self._saml_handler = hs.get_saml_handler()
|
self._saml_handler = hs.get_saml_handler()
|
||||||
|
|
||||||
def get_sso_url(self, client_redirect_url: bytes) -> bytes:
|
async def get_sso_url(
|
||||||
|
self, request: SynapseRequest, client_redirect_url: bytes
|
||||||
|
) -> bytes:
|
||||||
return self._saml_handler.handle_redirect_request(client_redirect_url)
|
return self._saml_handler.handle_redirect_request(client_redirect_url)
|
||||||
|
|
||||||
|
|
||||||
class OIDCRedirectServlet(RestServlet):
|
class OIDCRedirectServlet(BaseSSORedirectServlet):
|
||||||
"""Implementation for /login/sso/redirect for the OIDC login flow."""
|
"""Implementation for /login/sso/redirect for the OIDC login flow."""
|
||||||
|
|
||||||
PATTERNS = client_patterns("/login/sso/redirect", v1=True)
|
PATTERNS = client_patterns("/login/sso/redirect", v1=True)
|
||||||
|
@ -477,12 +484,12 @@ class OIDCRedirectServlet(RestServlet):
|
||||||
def __init__(self, hs):
|
def __init__(self, hs):
|
||||||
self._oidc_handler = hs.get_oidc_handler()
|
self._oidc_handler = hs.get_oidc_handler()
|
||||||
|
|
||||||
async def on_GET(self, request):
|
async def get_sso_url(
|
||||||
args = request.args
|
self, request: SynapseRequest, client_redirect_url: bytes
|
||||||
if b"redirectUrl" not in args:
|
) -> bytes:
|
||||||
return 400, "Redirect URL not specified for SSO auth"
|
return await self._oidc_handler.handle_redirect_request(
|
||||||
client_redirect_url = args[b"redirectUrl"][0]
|
request, client_redirect_url
|
||||||
await self._oidc_handler.handle_redirect_request(request, client_redirect_url)
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_servlets(hs, http_server):
|
def register_servlets(hs, http_server):
|
||||||
|
|
|
@ -131,14 +131,19 @@ class AuthRestServlet(RestServlet):
|
||||||
self.registration_handler = hs.get_registration_handler()
|
self.registration_handler = hs.get_registration_handler()
|
||||||
|
|
||||||
# SSO configuration.
|
# SSO configuration.
|
||||||
self._saml_enabled = hs.config.saml2_enabled
|
|
||||||
if self._saml_enabled:
|
|
||||||
self._saml_handler = hs.get_saml_handler()
|
|
||||||
self._cas_enabled = hs.config.cas_enabled
|
self._cas_enabled = hs.config.cas_enabled
|
||||||
if self._cas_enabled:
|
if self._cas_enabled:
|
||||||
self._cas_handler = hs.get_cas_handler()
|
self._cas_handler = hs.get_cas_handler()
|
||||||
self._cas_server_url = hs.config.cas_server_url
|
self._cas_server_url = hs.config.cas_server_url
|
||||||
self._cas_service_url = hs.config.cas_service_url
|
self._cas_service_url = hs.config.cas_service_url
|
||||||
|
self._saml_enabled = hs.config.saml2_enabled
|
||||||
|
if self._saml_enabled:
|
||||||
|
self._saml_handler = hs.get_saml_handler()
|
||||||
|
self._oidc_enabled = hs.config.oidc_enabled
|
||||||
|
if self._oidc_enabled:
|
||||||
|
self._oidc_handler = hs.get_oidc_handler()
|
||||||
|
self._cas_server_url = hs.config.cas_server_url
|
||||||
|
self._cas_service_url = hs.config.cas_service_url
|
||||||
|
|
||||||
async def on_GET(self, request, stagetype):
|
async def on_GET(self, request, stagetype):
|
||||||
session = parse_string(request, "session")
|
session = parse_string(request, "session")
|
||||||
|
@ -172,11 +177,17 @@ class AuthRestServlet(RestServlet):
|
||||||
)
|
)
|
||||||
|
|
||||||
elif self._saml_enabled:
|
elif self._saml_enabled:
|
||||||
client_redirect_url = ""
|
client_redirect_url = b""
|
||||||
sso_redirect_url = self._saml_handler.handle_redirect_request(
|
sso_redirect_url = self._saml_handler.handle_redirect_request(
|
||||||
client_redirect_url, session
|
client_redirect_url, session
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif self._oidc_enabled:
|
||||||
|
client_redirect_url = b""
|
||||||
|
sso_redirect_url = await self._oidc_handler.handle_redirect_request(
|
||||||
|
request, client_redirect_url, session
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise SynapseError(400, "Homeserver not configured for SSO.")
|
raise SynapseError(400, "Homeserver not configured for SSO.")
|
||||||
|
|
||||||
|
|
|
@ -292,11 +292,10 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def test_redirect_request(self):
|
def test_redirect_request(self):
|
||||||
"""The redirect request has the right arguments & generates a valid session cookie."""
|
"""The redirect request has the right arguments & generates a valid session cookie."""
|
||||||
req = Mock(spec=["addCookie", "redirect", "finish"])
|
req = Mock(spec=["addCookie"])
|
||||||
yield defer.ensureDeferred(
|
url = yield defer.ensureDeferred(
|
||||||
self.handler.handle_redirect_request(req, b"http://client/redirect")
|
self.handler.handle_redirect_request(req, b"http://client/redirect")
|
||||||
)
|
)
|
||||||
url = req.redirect.call_args[0][0]
|
|
||||||
url = urlparse(url)
|
url = urlparse(url)
|
||||||
auth_endpoint = urlparse(AUTHORIZATION_ENDPOINT)
|
auth_endpoint = urlparse(AUTHORIZATION_ENDPOINT)
|
||||||
|
|
||||||
|
@ -382,7 +381,10 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
nonce = "nonce"
|
nonce = "nonce"
|
||||||
client_redirect_url = "http://client/redirect"
|
client_redirect_url = "http://client/redirect"
|
||||||
session = self.handler._generate_oidc_session_token(
|
session = self.handler._generate_oidc_session_token(
|
||||||
state=state, nonce=nonce, client_redirect_url=client_redirect_url,
|
state=state,
|
||||||
|
nonce=nonce,
|
||||||
|
client_redirect_url=client_redirect_url,
|
||||||
|
ui_auth_session_id=None,
|
||||||
)
|
)
|
||||||
request.getCookie.return_value = session
|
request.getCookie.return_value = session
|
||||||
|
|
||||||
|
@ -472,7 +474,10 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
# Mismatching session
|
# Mismatching session
|
||||||
session = self.handler._generate_oidc_session_token(
|
session = self.handler._generate_oidc_session_token(
|
||||||
state="state", nonce="nonce", client_redirect_url="http://client/redirect",
|
state="state",
|
||||||
|
nonce="nonce",
|
||||||
|
client_redirect_url="http://client/redirect",
|
||||||
|
ui_auth_session_id=None,
|
||||||
)
|
)
|
||||||
request.args = {}
|
request.args = {}
|
||||||
request.args[b"state"] = [b"mismatching state"]
|
request.args[b"state"] = [b"mismatching state"]
|
||||||
|
|
Loading…
Reference in a new issue