Skip to content

Commit

Permalink
Merge branch 'master' of github.com:ITISFoundation/osparc-simcore int…
Browse files Browse the repository at this point in the history
…o improve-efs-3
  • Loading branch information
matusdrobuliak66 committed Oct 14, 2024
2 parents 5546201 + da15add commit 9de8a82
Show file tree
Hide file tree
Showing 16 changed files with 221 additions and 139 deletions.
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

0 comments on commit 9de8a82

Please sign in to comment.