Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 Fixes auth product error in vendor services 🚨 #6512

Merged
merged 25 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ printf "$$rows" "Rabbit Dashboard" "http://$(get_my_ip).nip.io:15672" admin admi
printf "$$rows" "Redis" "http://$(get_my_ip).nip.io:18081";\
printf "$$rows" "Storage S3 Minio" "http://$(get_my_ip).nip.io:9001" 12345678 12345678;\
printf "$$rows" "Traefik Dashboard" "http://$(get_my_ip).nip.io:8080/dashboard/";\
printf "$$rows" "Vendor Manual (Fake)" "http://manual.$(get_my_ip).nip.io:9081";\

printf "\n%s\n" "⚠️ if a DNS is not used (as displayed above), the interactive services started via dynamic-sidecar";\
echo "⚠️ will not be shown. The frontend accesses them via the uuid.services.YOUR_IP.nip.io:9081";
Expand Down
2 changes: 1 addition & 1 deletion services/docker-compose-dev-vendors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ services:
- io.simcore.zone=${TRAEFIK_SIMCORE_ZONE}
- traefik.enable=true
- traefik.docker.network=${SWARM_STACK_NAME}_default
# auth
# auth: https://doc.traefik.io/traefik/middlewares/http/forwardauth
- traefik.http.middlewares.${SWARM_STACK_NAME}_manual-auth.forwardauth.address=http://${WEBSERVER_HOST}:${WEBSERVER_PORT}/v0/auth:check
- traefik.http.middlewares.${SWARM_STACK_NAME}_manual-auth.forwardauth.trustForwardHeader=true
- traefik.http.middlewares.${SWARM_STACK_NAME}_manual-auth.forwardauth.authResponseHeaders=Set-Cookie,osparc-sc
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings):
env=["WEBSERVER_LOGLEVEL", "LOG_LEVEL", "LOGLEVEL"],
# NOTE: suffix '_LOGLEVEL' is used overall
)

WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED: bool = Field(
default=False,
env=["WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED", "LOG_FORMAT_LOCAL_DEV_ENABLED"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
from servicelib.request_keys import RQT_USERID_KEY

from ..products.api import get_product_name
from ..security.api import AuthContextDict, check_user_authorized, check_user_permission
from ..security.api import (
PERMISSION_PRODUCT_LOGIN_KEY,
AuthContextDict,
check_user_authorized,
check_user_permission,
)


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

await check_user_permission(
request,
"product",
PERMISSION_PRODUCT_LOGIN_KEY,
context=AuthContextDict(
product_name=get_product_name(request),
authorized_uid=user_id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@


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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import textwrap
from collections import OrderedDict

from aiohttp import web
Expand All @@ -12,12 +13,25 @@
_logger = logging.getLogger(__name__)


def _get_default_product_name(app: web.Application) -> str:
product_name: str = app[f"{APP_PRODUCTS_KEY}_default"]
return product_name


def _discover_product_by_hostname(request: web.Request) -> str | None:
products: OrderedDict[str, Product] = request.app[APP_PRODUCTS_KEY]
#
# SEE https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host
# SEE https://doc.traefik.io/traefik/getting-started/faq/#what-are-the-forwarded-headers-when-proxying-http-requests
originating_hosts = [
request.headers.get("X-Forwarded-Host"),
request.host,
]
for product in products.values():
if product.host_regex.search(request.host):
product_name: str = product.name
return product_name
for host in originating_hosts:
if host and product.host_regex.search(host):
product_name: str = product.name
return product_name
return None


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


def _get_app_default_product_name(request: web.Request) -> str:
product_name: str = request.app[f"{APP_PRODUCTS_KEY}_default"]
return product_name
def _get_debug_msg(request: web.Request):
return "\n".join(
[
f"{request.url=}",
f"{request.host=}",
f"{request.remote=}",
*[f"{k}:{request.headers[k][:20]}" for k in request.headers],
f"{request.headers.get('X-Forwarded-Host')=}",
f"{request.get(RQ_PRODUCT_KEY)=}",
]
)


@web.middleware
Expand All @@ -43,35 +65,37 @@ async def discover_product_middleware(request: web.Request, handler: Handler):
- request[RQ_PRODUCT_KEY] is set to discovered product in 3 types of entrypoints
- if no product discovered, then it is set to default
"""
# - API entrypoints
# - /static info for front-end

if (
# - API entrypoints
# - /static info for front-end
# - socket-io
request.path.startswith(f"/{API_VTAG}")
or request.path == "/static-frontend-data.json"
or request.path == "/socket.io/"
or request.path in {"/static-frontend-data.json", "/socket.io/"}
):
product_name = (
request[RQ_PRODUCT_KEY] = (
_discover_product_by_request_header(request)
or _discover_product_by_hostname(request)
or _get_app_default_product_name(request)
or _get_default_product_name(request.app)
)
request[RQ_PRODUCT_KEY] = product_name

# - Publications entrypoint: redirections from other websites. SEE studies_access.py::access_study
# - Root entrypoint: to serve front-end apps
elif (
request.path.startswith("/study/")
or request.path.startswith("/view")
or request.path == "/"
):
product_name = _discover_product_by_hostname(
request
) or _get_app_default_product_name(request)

request[RQ_PRODUCT_KEY] = product_name
else:
# - Publications entrypoint: redirections from other websites. SEE studies_access.py::access_study
# - Root entrypoint: to serve front-end apps
assert ( # nosec
request.path.startswith("/dev/")
or request.path.startswith("/study/")
or request.path.startswith("/view")
or request.path == "/"
)
request[RQ_PRODUCT_KEY] = _discover_product_by_hostname(
request
) or _get_default_product_name(request.app)

assert request.get(RQ_PRODUCT_KEY) is not None or request.path.startswith( # nosec
"/dev/doc"
_logger.debug(
"Product middleware result: \n%s\n",
textwrap.indent(_get_debug_msg(request), " "),
)
assert request[RQ_PRODUCT_KEY] # nosec

return await handler(request)
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
""" AUTHoriZation (auth) policy:
""" AUTHoriZation (auth) policy

"""

import contextlib
Expand All @@ -23,7 +24,7 @@
has_access_by_role,
)
from ._authz_db import AuthInfoDict, get_active_user_or_none, is_user_in_product_name
from ._constants import MSG_AUTH_NOT_AVAILABLE
from ._constants import MSG_AUTH_NOT_AVAILABLE, PERMISSION_PRODUCT_LOGIN_KEY
from ._identity_api import IdentityStr

_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -132,7 +133,7 @@ async def permits(
context = context or AuthContextDict()

# product access
if permission == "product":
if permission == PERMISSION_PRODUCT_LOGIN_KEY:
product_name = context.get("product_name")
ok: bool = product_name is not None and await self._has_access_to_product(
user_id=auth_info["id"], product_name=product_name
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Final

MSG_AUTH_NOT_AVAILABLE: Final[str] = "Authentication service is temporary unavailable"

PERMISSION_PRODUCT_LOGIN_KEY: Final[str] = "product.login"
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@
NOTE: DO NOT USE aiohttp_security.api directly but use this interface instead
"""


import aiohttp_security.api # type: ignore[import-untyped]
import passlib.hash
from aiohttp import web
from models_library.users import UserID

from ._authz_access_model import AuthContextDict, OptionalContext, RoleBasedAccessModel
from ._authz_policy import AuthorizationPolicy
from ._constants import PERMISSION_PRODUCT_LOGIN_KEY
from ._identity_api import forget_identity, remember_identity

assert PERMISSION_PRODUCT_LOGIN_KEY # nosec


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

allowed = await aiohttp_security.api.permits(request, permission, context)
if not allowed:
raise web.HTTPForbidden(reason=f"Not sufficient access rights for {permission}")
raise web.HTTPForbidden(
reason=f"You do not have sufficient access rights for {permission}"
)


#
Expand Down Expand Up @@ -93,5 +97,6 @@ def check_password(password: str, password_hash: str) -> bool:
"forget_identity",
"get_access_model",
"is_anonymous",
"PERMISSION_PRODUCT_LOGIN_KEY",
"remember_identity",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""
Extends aiohttp_session.cookie_storage

"""

import logging
import time

import aiohttp_session
from aiohttp import web
from aiohttp_session.cookie_storage import EncryptedCookieStorage

from .errors import SessionValueError

_logger = logging.getLogger(__name__)


def _share_cookie_across_all_subdomains(
request: web.BaseRequest, params: aiohttp_session._CookieParams
) -> aiohttp_session._CookieParams:
"""
Shares cookie across all subdomains, by appending a dot (`.`) in front of the domain name
overwrite domain from `None` (browser sets `example.com`) to `.example.com`
"""
host = request.url.host
if host is None:
raise SessionValueError(
invalid="host", host=host, request_url=request.url, params=params
)

params["domain"] = f".{host.lstrip('.')}"

return params


class SharedCookieEncryptedCookieStorage(EncryptedCookieStorage):
async def save_session(
self,
request: web.Request,
response: web.StreamResponse,
session: aiohttp_session.Session,
) -> None:
# link response to originating request (allows to detect the orginal request url)
response._req = request # pylint:disable=protected-access # noqa: SLF001

await super().save_session(request, response, session)

def save_cookie(
self,
response: web.StreamResponse,
cookie_data: str,
*,
max_age: int | None = None,
) -> None:

params = self._cookie_params.copy()
request = response._req # pylint:disable=protected-access # noqa: SLF001
if not request:
raise SessionValueError(
invalid="request",
invalid_request=request,
response=response,
params=params,
)

params = _share_cookie_across_all_subdomains(request, params)

# --------------------------------------------------------
# WARNING: the code below is taken and adapted from the superclass
# implementation `EncryptedCookieStorage.save_cookie`
# Adjust in case the base library changes.
assert aiohttp_session.__version__ == "2.11.0" # nosec
# --------------------------------------------------------

if max_age is not None:
params["max_age"] = max_age
t = time.gmtime(time.time() + max_age)
params["expires"] = time.strftime("%a, %d-%b-%Y %T GMT", t)

if not cookie_data:
response.del_cookie(
self._cookie_name,
domain=params.get("domain"),
path=params.get("path", "/"),
)
else:
response.set_cookie(self._cookie_name, cookie_data, **params)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from ..errors import WebServerBaseError


class SessionValueError(WebServerBaseError, ValueError):
msg_template = "Invalid {invalid} in session"
Loading
Loading