Skip to content

Commit da15add

Browse files
authored
πŸ› Fixes auth product error in vendor services 🚨 (#6512)
1 parent f3e838b commit da15add

File tree

16 files changed

+221
-139
lines changed

16 files changed

+221
-139
lines changed

β€ŽMakefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ printf "$$rows" "Rabbit Dashboard" "http://$(get_my_ip).nip.io:15672" admin admi
332332
printf "$$rows" "Redis" "http://$(get_my_ip).nip.io:18081";\
333333
printf "$$rows" "Storage S3 Minio" "http://$(get_my_ip).nip.io:9001" 12345678 12345678;\
334334
printf "$$rows" "Traefik Dashboard" "http://$(get_my_ip).nip.io:8080/dashboard/";\
335+
printf "$$rows" "Vendor Manual (Fake)" "http://manual.$(get_my_ip).nip.io:9081";\
335336

336337
printf "\n%s\n" "⚠️ if a DNS is not used (as displayed above), the interactive services started via dynamic-sidecar";\
337338
echo "⚠️ will not be shown. The frontend accesses them via the uuid.services.YOUR_IP.nip.io:9081";

β€Žservices/docker-compose-dev-vendors.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ services:
1414
- io.simcore.zone=${TRAEFIK_SIMCORE_ZONE}
1515
- traefik.enable=true
1616
- traefik.docker.network=${SWARM_STACK_NAME}_default
17-
# auth
17+
# auth: https://doc.traefik.io/traefik/middlewares/http/forwardauth
1818
- traefik.http.middlewares.${SWARM_STACK_NAME}_manual-auth.forwardauth.address=http://${WEBSERVER_HOST}:${WEBSERVER_PORT}/v0/auth:check
1919
- traefik.http.middlewares.${SWARM_STACK_NAME}_manual-auth.forwardauth.trustForwardHeader=true
2020
- traefik.http.middlewares.${SWARM_STACK_NAME}_manual-auth.forwardauth.authResponseHeaders=Set-Cookie,osparc-sc

β€Žservices/web/server/src/simcore_service_webserver/application_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings):
108108
env=["WEBSERVER_LOGLEVEL", "LOG_LEVEL", "LOGLEVEL"],
109109
# NOTE: suffix '_LOGLEVEL' is used overall
110110
)
111+
111112
WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED: bool = Field(
112113
default=False,
113114
env=["WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED", "LOG_FORMAT_LOCAL_DEV_ENABLED"],

β€Žservices/web/server/src/simcore_service_webserver/login/decorators.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
from servicelib.request_keys import RQT_USERID_KEY
77

88
from ..products.api import get_product_name
9-
from ..security.api import AuthContextDict, check_user_authorized, check_user_permission
9+
from ..security.api import (
10+
PERMISSION_PRODUCT_LOGIN_KEY,
11+
AuthContextDict,
12+
check_user_authorized,
13+
check_user_permission,
14+
)
1015

1116

1217
def login_required(handler: HandlerAnyReturn) -> HandlerAnyReturn:
@@ -53,7 +58,7 @@ async def _wrapper(request: web.Request):
5358

5459
await check_user_permission(
5560
request,
56-
"product",
61+
PERMISSION_PRODUCT_LOGIN_KEY,
5762
context=AuthContextDict(
5863
product_name=get_product_name(request),
5964
authorized_uid=user_id,

β€Žservices/web/server/src/simcore_service_webserver/products/_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717

1818
def get_product_name(request: web.Request) -> str:
19+
"""Returns product name in request but might be undefined"""
1920
product_name: str = request[RQ_PRODUCT_KEY]
2021
return product_name
2122

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import textwrap
23
from collections import OrderedDict
34

45
from aiohttp import web
@@ -12,12 +13,25 @@
1213
_logger = logging.getLogger(__name__)
1314

1415

16+
def _get_default_product_name(app: web.Application) -> str:
17+
product_name: str = app[f"{APP_PRODUCTS_KEY}_default"]
18+
return product_name
19+
20+
1521
def _discover_product_by_hostname(request: web.Request) -> str | None:
1622
products: OrderedDict[str, Product] = request.app[APP_PRODUCTS_KEY]
23+
#
24+
# SEE https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host
25+
# SEE https://doc.traefik.io/traefik/getting-started/faq/#what-are-the-forwarded-headers-when-proxying-http-requests
26+
originating_hosts = [
27+
request.headers.get("X-Forwarded-Host"),
28+
request.host,
29+
]
1730
for product in products.values():
18-
if product.host_regex.search(request.host):
19-
product_name: str = product.name
20-
return product_name
31+
for host in originating_hosts:
32+
if host and product.host_regex.search(host):
33+
product_name: str = product.name
34+
return product_name
2135
return None
2236

2337

@@ -30,9 +44,17 @@ def _discover_product_by_request_header(request: web.Request) -> str | None:
3044
return None
3145

3246

33-
def _get_app_default_product_name(request: web.Request) -> str:
34-
product_name: str = request.app[f"{APP_PRODUCTS_KEY}_default"]
35-
return product_name
47+
def _get_debug_msg(request: web.Request):
48+
return "\n".join(
49+
[
50+
f"{request.url=}",
51+
f"{request.host=}",
52+
f"{request.remote=}",
53+
*[f"{k}:{request.headers[k][:20]}" for k in request.headers],
54+
f"{request.headers.get('X-Forwarded-Host')=}",
55+
f"{request.get(RQ_PRODUCT_KEY)=}",
56+
]
57+
)
3658

3759

3860
@web.middleware
@@ -43,35 +65,37 @@ async def discover_product_middleware(request: web.Request, handler: Handler):
4365
- request[RQ_PRODUCT_KEY] is set to discovered product in 3 types of entrypoints
4466
- if no product discovered, then it is set to default
4567
"""
46-
# - API entrypoints
47-
# - /static info for front-end
68+
4869
if (
70+
# - API entrypoints
71+
# - /static info for front-end
72+
# - socket-io
4973
request.path.startswith(f"/{API_VTAG}")
50-
or request.path == "/static-frontend-data.json"
51-
or request.path == "/socket.io/"
74+
or request.path in {"/static-frontend-data.json", "/socket.io/"}
5275
):
53-
product_name = (
76+
request[RQ_PRODUCT_KEY] = (
5477
_discover_product_by_request_header(request)
5578
or _discover_product_by_hostname(request)
56-
or _get_app_default_product_name(request)
79+
or _get_default_product_name(request.app)
5780
)
58-
request[RQ_PRODUCT_KEY] = product_name
59-
60-
# - Publications entrypoint: redirections from other websites. SEE studies_access.py::access_study
61-
# - Root entrypoint: to serve front-end apps
62-
elif (
63-
request.path.startswith("/study/")
64-
or request.path.startswith("/view")
65-
or request.path == "/"
66-
):
67-
product_name = _discover_product_by_hostname(
68-
request
69-
) or _get_app_default_product_name(request)
7081

71-
request[RQ_PRODUCT_KEY] = product_name
82+
else:
83+
# - Publications entrypoint: redirections from other websites. SEE studies_access.py::access_study
84+
# - Root entrypoint: to serve front-end apps
85+
assert ( # nosec
86+
request.path.startswith("/dev/")
87+
or request.path.startswith("/study/")
88+
or request.path.startswith("/view")
89+
or request.path == "/"
90+
)
91+
request[RQ_PRODUCT_KEY] = _discover_product_by_hostname(
92+
request
93+
) or _get_default_product_name(request.app)
7294

73-
assert request.get(RQ_PRODUCT_KEY) is not None or request.path.startswith( # nosec
74-
"/dev/doc"
95+
_logger.debug(
96+
"Product middleware result: \n%s\n",
97+
textwrap.indent(_get_debug_msg(request), " "),
7598
)
99+
assert request[RQ_PRODUCT_KEY] # nosec
76100

77101
return await handler(request)

β€Žservices/web/server/src/simcore_service_webserver/security/_authz_policy.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
""" AUTHoriZation (auth) policy:
1+
""" AUTHoriZation (auth) policy
2+
23
"""
34

45
import contextlib
@@ -23,7 +24,7 @@
2324
has_access_by_role,
2425
)
2526
from ._authz_db import AuthInfoDict, get_active_user_or_none, is_user_in_product_name
26-
from ._constants import MSG_AUTH_NOT_AVAILABLE
27+
from ._constants import MSG_AUTH_NOT_AVAILABLE, PERMISSION_PRODUCT_LOGIN_KEY
2728
from ._identity_api import IdentityStr
2829

2930
_logger = logging.getLogger(__name__)
@@ -132,7 +133,7 @@ async def permits(
132133
context = context or AuthContextDict()
133134

134135
# product access
135-
if permission == "product":
136+
if permission == PERMISSION_PRODUCT_LOGIN_KEY:
136137
product_name = context.get("product_name")
137138
ok: bool = product_name is not None and await self._has_access_to_product(
138139
user_id=auth_info["id"], product_name=product_name
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
from typing import Final
22

33
MSG_AUTH_NOT_AVAILABLE: Final[str] = "Authentication service is temporary unavailable"
4+
5+
PERMISSION_PRODUCT_LOGIN_KEY: Final[str] = "product.login"

β€Žservices/web/server/src/simcore_service_webserver/security/api.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@
66
NOTE: DO NOT USE aiohttp_security.api directly but use this interface instead
77
"""
88

9-
109
import aiohttp_security.api # type: ignore[import-untyped]
1110
import passlib.hash
1211
from aiohttp import web
1312
from models_library.users import UserID
1413

1514
from ._authz_access_model import AuthContextDict, OptionalContext, RoleBasedAccessModel
1615
from ._authz_policy import AuthorizationPolicy
16+
from ._constants import PERMISSION_PRODUCT_LOGIN_KEY
1717
from ._identity_api import forget_identity, remember_identity
1818

19+
assert PERMISSION_PRODUCT_LOGIN_KEY # nosec
20+
1921

2022
def get_access_model(app: web.Application) -> RoleBasedAccessModel:
2123
autz_policy: AuthorizationPolicy = app[aiohttp_security.api.AUTZ_KEY]
@@ -64,7 +66,9 @@ async def check_user_permission(
6466

6567
allowed = await aiohttp_security.api.permits(request, permission, context)
6668
if not allowed:
67-
raise web.HTTPForbidden(reason=f"Not sufficient access rights for {permission}")
69+
raise web.HTTPForbidden(
70+
reason=f"You do not have sufficient access rights for {permission}"
71+
)
6872

6973

7074
#
@@ -93,5 +97,6 @@ def check_password(password: str, password_hash: str) -> bool:
9397
"forget_identity",
9498
"get_access_model",
9599
"is_anonymous",
100+
"PERMISSION_PRODUCT_LOGIN_KEY",
96101
"remember_identity",
97102
)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Extends aiohttp_session.cookie_storage
3+
4+
"""
5+
6+
import logging
7+
import time
8+
9+
import aiohttp_session
10+
from aiohttp import web
11+
from aiohttp_session.cookie_storage import EncryptedCookieStorage
12+
13+
from .errors import SessionValueError
14+
15+
_logger = logging.getLogger(__name__)
16+
17+
18+
def _share_cookie_across_all_subdomains(
19+
request: web.BaseRequest, params: aiohttp_session._CookieParams
20+
) -> aiohttp_session._CookieParams:
21+
"""
22+
Shares cookie across all subdomains, by appending a dot (`.`) in front of the domain name
23+
overwrite domain from `None` (browser sets `example.com`) to `.example.com`
24+
"""
25+
host = request.url.host
26+
if host is None:
27+
raise SessionValueError(
28+
invalid="host", host=host, request_url=request.url, params=params
29+
)
30+
31+
params["domain"] = f".{host.lstrip('.')}"
32+
33+
return params
34+
35+
36+
class SharedCookieEncryptedCookieStorage(EncryptedCookieStorage):
37+
async def save_session(
38+
self,
39+
request: web.Request,
40+
response: web.StreamResponse,
41+
session: aiohttp_session.Session,
42+
) -> None:
43+
# link response to originating request (allows to detect the orginal request url)
44+
response._req = request # pylint:disable=protected-access # noqa: SLF001
45+
46+
await super().save_session(request, response, session)
47+
48+
def save_cookie(
49+
self,
50+
response: web.StreamResponse,
51+
cookie_data: str,
52+
*,
53+
max_age: int | None = None,
54+
) -> None:
55+
56+
params = self._cookie_params.copy()
57+
request = response._req # pylint:disable=protected-access # noqa: SLF001
58+
if not request:
59+
raise SessionValueError(
60+
invalid="request",
61+
invalid_request=request,
62+
response=response,
63+
params=params,
64+
)
65+
66+
params = _share_cookie_across_all_subdomains(request, params)
67+
68+
# --------------------------------------------------------
69+
# WARNING: the code below is taken and adapted from the superclass
70+
# implementation `EncryptedCookieStorage.save_cookie`
71+
# Adjust in case the base library changes.
72+
assert aiohttp_session.__version__ == "2.11.0" # nosec
73+
# --------------------------------------------------------
74+
75+
if max_age is not None:
76+
params["max_age"] = max_age
77+
t = time.gmtime(time.time() + max_age)
78+
params["expires"] = time.strftime("%a, %d-%b-%Y %T GMT", t)
79+
80+
if not cookie_data:
81+
response.del_cookie(
82+
self._cookie_name,
83+
domain=params.get("domain"),
84+
path=params.get("path", "/"),
85+
)
86+
else:
87+
response.set_cookie(self._cookie_name, cookie_data, **params)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from ..errors import WebServerBaseError
2+
3+
4+
class SessionValueError(WebServerBaseError, ValueError):
5+
msg_template = "Invalid {invalid} in session"

0 commit comments

Comments
Β (0)