Skip to content

🎨 Adds authentication for new style dynamic services and platform vendor services ⚠️ #6484

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

Merged
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
37162be
added mock unauthenticated manual service
Oct 2, 2024
85733af
cookies
Oct 2, 2024
0b53e9f
exposed manual
Oct 2, 2024
61a396c
dynamic-sidecar is now authenticated
Oct 2, 2024
9aa2728
extended EncryptedCookieStorage
Oct 2, 2024
9fb2a08
Merge remote-tracking branch 'upstream/master' into pr-osparc-manual-…
Oct 2, 2024
4673a62
using lightweight auth check endpoint
Oct 2, 2024
eb94357
director-v2 reads webserver endpoint form env vars
Oct 2, 2024
4445b34
refactor
Oct 2, 2024
1252409
link with cookie name
Oct 2, 2024
c8e8152
readme
Oct 2, 2024
5d8e115
fixed failing tests
Oct 2, 2024
e0c3398
added test for /auth:check
Oct 2, 2024
4f472d5
added notes for the future
Oct 2, 2024
05d93d3
refactor
Oct 2, 2024
3a69ec9
Merge remote-tracking branch 'upstream/master' into pr-osparc-manual-…
Oct 2, 2024
fe002fd
simplify service declaration
Oct 2, 2024
20a4145
ignore manual
Oct 2, 2024
8507df7
moved to vendors stack
Oct 2, 2024
212af33
extracted env vars to configure manual service
Oct 2, 2024
e6642b7
remove
Oct 2, 2024
199814b
Merge remote-tracking branch 'upstream/master' into pr-osparc-manual-…
Oct 2, 2024
132845b
disable manual by default
Oct 3, 2024
9fabb8a
Merge remote-tracking branch 'upstream/master' into pr-osparc-manual-…
Oct 3, 2024
d4f8626
revert this change
Oct 3, 2024
b51e457
fix failing tests
Oct 3, 2024
8e35dfc
Merge remote-tracking branch 'upstream/master' into pr-osparc-manual-…
Oct 3, 2024
3f2c54c
revert
Oct 3, 2024
066cf32
Merge remote-tracking branch 'upstream/master' into pr-osparc-manual-…
Oct 4, 2024
54dcd5f
refactor placement of services in stacks
Oct 4, 2024
4b4ba17
string refenreces
Oct 4, 2024
626307e
rename
Oct 4, 2024
12d1360
refactor interface
Oct 7, 2024
ef6b17f
added new tests and fixtures
Oct 7, 2024
1eb0ab0
refactor
Oct 7, 2024
62e96c3
fixed broken tests
Oct 7, 2024
bbb5ec9
Merge branch 'master' into pr-osparc-manual-for-logged-in-users
GitHK Oct 8, 2024
7aa314f
fixed broken test
Oct 8, 2024
752a2a6
finally fixed test
Oct 8, 2024
cf4b23f
Merge branch 'pr-osparc-manual-for-logged-in-users' of github.com:Git…
Oct 8, 2024
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
7 changes: 7 additions & 0 deletions .env-devel
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,13 @@ STORAGE_PROFILING=1

SWARM_STACK_NAME=master-simcore

## VENDOR DEVELOPMENT SERVICES ---
VENDOR_DEV_MANUAL_IMAGE=containous/whoami
VENDOR_DEV_MANUAL_REPLICAS=1
VENDOR_DEV_MANUAL_SUBDOMAIN=manual

## VENDOR DEVELOPMENT SERVICES ---

WB_API_WEBSERVER_HOST=wb-api-server
WB_API_WEBSERVER_PORT=8080

Expand Down
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ CPU_COUNT = $(shell cat /proc/cpuinfo | grep processor | wc -l )
services/docker-compose.local.yml \
> $@

.stack-vendor-services.yml: .env $(docker-compose-configs)
# Creating config for vendors stack to $@
@scripts/docker/docker-stack-config.bash -e $< \
services/docker-compose-dev-vendors.yml \
> $@

.stack-ops.yml: .env $(docker-compose-configs)
# Creating config for ops stack to $@
Expand All @@ -288,7 +293,11 @@ endif



.PHONY: up-devel up-prod up-prod-ci up-version up-latest .deploy-ops
.PHONY: up-devel up-prod up-prod-ci up-version up-latest .deploy-ops .deploy-vendors

.deploy-vendors: .stack-vendor-services.yml
# Deploy stack 'vendors'
docker stack deploy --detach=true --with-registry-auth -c $< vendors

.deploy-ops: .stack-ops.yml
# Deploy stack 'ops'
Expand Down Expand Up @@ -338,6 +347,7 @@ up-devel: .stack-simcore-development.yml .init-swarm $(CLIENT_WEB_OUTPUT) ## Dep
@$(MAKE_C) services/dask-sidecar certificates
# Deploy stack $(SWARM_STACK_NAME) [back-end]
@docker stack deploy --detach=true --with-registry-auth -c $< $(SWARM_STACK_NAME)
@$(MAKE) .deploy-vendors
@$(MAKE) .deploy-ops
@$(_show_endpoints)
@$(MAKE_C) services/static-webserver/client follow-dev-logs
Expand All @@ -348,6 +358,7 @@ up-devel-frontend: .stack-simcore-development-frontend.yml .init-swarm ## Every
@$(MAKE_C) services/dask-sidecar certificates
# Deploy stack $(SWARM_STACK_NAME) [back-end]
@docker stack deploy --detach=true --with-registry-auth -c $< $(SWARM_STACK_NAME)
@$(MAKE) .deploy-vendors
@$(MAKE) .deploy-ops
@$(_show_endpoints)
@$(MAKE_C) services/static-webserver/client follow-dev-logs
Expand All @@ -358,6 +369,7 @@ ifeq ($(target),)
@$(MAKE_C) services/dask-sidecar certificates
# Deploy stack $(SWARM_STACK_NAME)
@docker stack deploy --detach=true --with-registry-auth -c $< $(SWARM_STACK_NAME)
@$(MAKE) .deploy-vendors
@$(MAKE) .deploy-ops
else
# deploys ONLY $(target) service
Expand All @@ -369,6 +381,7 @@ up-version: .stack-simcore-version.yml .init-swarm ## Deploys versioned stack '$
@$(MAKE_C) services/dask-sidecar certificates
# Deploy stack $(SWARM_STACK_NAME)
@docker stack deploy --detach=true --with-registry-auth -c $< $(SWARM_STACK_NAME)
@$(MAKE) .deploy-vendors
@$(MAKE) .deploy-ops
@$(_show_endpoints)

Expand Down
16 changes: 16 additions & 0 deletions api/specs/web-server/_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from simcore_service_webserver._meta import API_VTAG
from simcore_service_webserver.login._2fa_handlers import Resend2faBody
from simcore_service_webserver.login._auth_handlers import (
CheckAuthBody,
LoginBody,
LoginNextPage,
LoginTwoFactorAuthBody,
Expand Down Expand Up @@ -155,6 +156,21 @@ async def logout(_body: LogoutBody):
"""user logout"""


@router.get(
"/auth:check",
response_model=Envelope[CheckAuthBody],
operation_id="check_authentication",
responses={
status.HTTP_401_UNAUTHORIZED: {
"model": Envelope[Error],
"description": "unauthorized reset due to invalid token code",
}
},
)
async def check_auth():
"""checks if user is autheticated in the platform"""


@router.post(
"/auth/reset-password",
response_model=Envelope[Log],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pydantic import Field
from settings_library.base import BaseCustomSettings
from settings_library.webserver import WebServerSettings

from .egress_proxy import EgressProxySettings
from .proxy import DynamicSidecarProxySettings
Expand Down Expand Up @@ -29,3 +30,5 @@ class DynamicServicesSettings(BaseCustomSettings):
DYNAMIC_SIDECAR_PLACEMENT_SETTINGS: PlacementSettings = Field(
auto_default_from_env=True
)

WEBSERVER_SETTINGS: WebServerSettings = Field(auto_default_from_env=True)
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
)
from pydantic import ByteSize
from servicelib.common_headers import X_SIMCORE_USER_AGENT
from settings_library import webserver
from settings_library.utils_session import DEFAULT_SESSION_COOKIE_NAME

from ....core.dynamic_services_settings import DynamicServicesSettings
from ....core.dynamic_services_settings.proxy import DynamicSidecarProxySettings
Expand Down Expand Up @@ -43,6 +45,9 @@ def get_dynamic_proxy_spec(
dynamic_services_scheduler_settings: DynamicServicesSchedulerSettings = (
dynamic_services_settings.DYNAMIC_SCHEDULER
)
webserver_settings: webserver.WebServerSettings = (
dynamic_services_settings.WEBSERVER_SETTINGS
)

mounts = [
# docker socket needed to use the docker api
Expand Down Expand Up @@ -77,9 +82,11 @@ def get_dynamic_proxy_spec(
"io.simcore.zone": f"{dynamic_services_scheduler_settings.TRAEFIK_SIMCORE_ZONE}",
"traefik.docker.network": swarm_network_name,
"traefik.enable": "true",
# security
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-security-headers.headers.accesscontrolallowcredentials": "true",
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-security-headers.headers.customresponseheaders.Content-Security-Policy": f"frame-ancestors {scheduler_data.request_dns} {scheduler_data.node_uuid}.services.{scheduler_data.request_dns}",
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-security-headers.headers.accesscontrolallowmethods": "GET,OPTIONS,PUT,POST,DELETE,PATCH,HEAD",
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-security-headers.headers.accesscontrolallowheaders": f"{X_SIMCORE_USER_AGENT}",
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-security-headers.headers.accesscontrolallowheaders": f"{X_SIMCORE_USER_AGENT},Set-Cookie",
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-security-headers.headers.accessControlAllowOriginList": ",".join(
[
f"{scheduler_data.request_scheme}://{scheduler_data.request_dns}",
Expand All @@ -88,11 +95,22 @@ def get_dynamic_proxy_spec(
),
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-security-headers.headers.accesscontrolmaxage": "100",
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-security-headers.headers.addvaryheader": "true",
# auth
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-auth.forwardauth.address": f"{webserver_settings.api_base_url}/auth:check",
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-auth.forwardauth.trustForwardHeader": "true",
f"traefik.http.middlewares.{scheduler_data.proxy_service_name}-auth.forwardauth.authResponseHeaders": f"Set-Cookie,{DEFAULT_SESSION_COOKIE_NAME}",
# routing
f"traefik.http.services.{scheduler_data.proxy_service_name}.loadbalancer.server.port": "80",
f"traefik.http.routers.{scheduler_data.proxy_service_name}.entrypoints": "http",
f"traefik.http.routers.{scheduler_data.proxy_service_name}.priority": "10",
f"traefik.http.routers.{scheduler_data.proxy_service_name}.rule": rf"HostRegexp(`{scheduler_data.node_uuid}\.services\.(?P<host>.+)`)",
f"traefik.http.routers.{scheduler_data.proxy_service_name}.middlewares": f"{dynamic_services_scheduler_settings.SWARM_STACK_NAME}_gzip@swarm, {scheduler_data.proxy_service_name}-security-headers",
f"traefik.http.routers.{scheduler_data.proxy_service_name}.middlewares": ",".join(
[
f"{dynamic_services_scheduler_settings.SWARM_STACK_NAME}_gzip@swarm",
f"{scheduler_data.proxy_service_name}-security-headers",
f"{scheduler_data.proxy_service_name}-auth",
]
),
"dynamic_type": "dynamic-sidecar", # tagged as dynamic service
}
| StandardSimcoreDockerLabels(
Expand Down
36 changes: 36 additions & 0 deletions services/docker-compose-dev-vendors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@

# NOTE: this stack is only for development and testing of vendor services.
# the actualy code is deployed inside the ops repository.

services:

manual:
image: ${VENDOR_DEV_MANUAL_IMAGE}
init: true
hostname: "{{.Node.Hostname}}-{{.Task.Slot}}"
deploy:
replicas: ${VENDOR_DEV_MANUAL_REPLICAS}
labels:
- io.simcore.zone=${TRAEFIK_SIMCORE_ZONE}
- traefik.enable=true
- traefik.docker.network=${SWARM_STACK_NAME}_default
# auth
- 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
# routing
- traefik.http.services.${SWARM_STACK_NAME}_manual.loadbalancer.server.port=80
- traefik.http.services.${SWARM_STACK_NAME}_manual.loadbalancer.healthcheck.path=/
- traefik.http.services.${SWARM_STACK_NAME}_manual.loadbalancer.healthcheck.interval=2000ms
- traefik.http.services.${SWARM_STACK_NAME}_manual.loadbalancer.healthcheck.timeout=1000ms
- traefik.http.routers.${SWARM_STACK_NAME}_manual.entrypoints=http
- traefik.http.routers.${SWARM_STACK_NAME}_manual.priority=10
- traefik.http.routers.${SWARM_STACK_NAME}_manual.rule=HostRegexp(`${VENDOR_DEV_MANUAL_SUBDOMAIN}\.(?P<host>.+)`)
- traefik.http.routers.${SWARM_STACK_NAME}_manual.middlewares=${SWARM_STACK_NAME}_gzip@swarm, ${SWARM_STACK_NAME}_manual-auth
networks:
- simcore_default

networks:
simcore_default:
name: ${SWARM_STACK_NAME}_default
external: true
3 changes: 3 additions & 0 deletions services/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,9 @@ services:
TRAEFIK_SIMCORE_ZONE: ${TRAEFIK_SIMCORE_ZONE}
TRACING_OPENTELEMETRY_COLLECTOR_ENDPOINT: ${TRACING_OPENTELEMETRY_COLLECTOR_ENDPOINT}
TRACING_OPENTELEMETRY_COLLECTOR_PORT: ${TRACING_OPENTELEMETRY_COLLECTOR_PORT}

WEBSERVER_HOST: ${WEBSERVER_HOST}
WEBSERVER_PORT: ${WEBSERVER_PORT}
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
deploy:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ qx.Class.define("osparc.data.model.IframeHandler", {
if (osparc.utils.Utils.isDevelopmentPlatform()) {
console.log("Connecting: about to fetch", srvUrl);
}
fetch(srvUrl)
fetch(srvUrl, {credentials: "include"})
.then(response => {
if (osparc.utils.Utils.isDevelopmentPlatform()) {
console.log("Connecting: fetch's response status", response.status);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,26 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Envelope_Log_'
/v0/auth:check:
get:
tags:
- auth
summary: Check Auth
description: checks if user is autheticated in the platform
operationId: check_authentication
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Envelope_CheckAuthBody_'
'401':
description: unauthorized reset due to invalid token code
content:
application/json:
schema:
$ref: '#/components/schemas/Envelope_Error_'
/v0/auth/reset-password:
post:
tags:
Expand Down Expand Up @@ -6467,6 +6487,10 @@ components:
format: password
writeOnly: true
additionalProperties: false
CheckAuthBody:
title: CheckAuthBody
type: object
properties: {}
CheckpointAnnotations:
title: CheckpointAnnotations
type: object
Expand Down Expand Up @@ -7116,6 +7140,14 @@ components:
$ref: '#/components/schemas/CatalogServiceGet'
error:
title: Error
Envelope_CheckAuthBody_:
title: Envelope[CheckAuthBody]
type: object
properties:
data:
$ref: '#/components/schemas/CheckAuthBody'
error:
title: Error
Envelope_CheckpointApiModel_:
title: Envelope[CheckpointApiModel]
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ class LoginNextPage(NextPage[CodePageParams]):
...


class CheckAuthBody(BaseModel):
...


@routes.post(f"/{API_VTAG}/auth/login", name="auth_login")
@on_success_grant_session_access_to(
name="auth_register_phone",
Expand Down Expand Up @@ -300,3 +304,18 @@ async def logout(request: web.Request) -> web.Response:
await forget_identity(request, response)

return response


@routes.get(f"/{API_VTAG}/auth:check", name="check_authentication")
@login_required
async def check_auth(request: web.Request) -> web.Response:
# lightweight endpoint for checking if users are authenticated
# used primarily by Traefik auth middleware to verify session cookies

# NOTE: for future development
# if databse access is added here, services like jupyter-math
# which load a lot of resources will have a big performance hit
# consider caching some properties required by this endpoit or rely on Redis

_ = request
return envelope_response(CheckAuthBody())
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import logging
import time

import aiohttp_session
from aiohttp import web
Expand All @@ -15,6 +16,45 @@
_logger = logging.getLogger(__name__)


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()

# share cookie accross all subdomains
# overwrite domain from `None` (browser sets `example.com`) to `.example.com`
request = response._req # pylint:disable=protected-access # noqa: SLF001
assert isinstance(request, web.Request) # nosec
params["domain"] = f".{request.url.host}"

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["domain"], path=params["path"]
)
else:
response.set_cookie(self._cookie_name, cookie_data, **params)


@app_module_setup(
__name__, ModuleCategory.ADDON, settings_name="WEBSERVER_SESSION", logger=_logger
)
Expand All @@ -34,7 +74,7 @@ def setup_session(app: web.Application):
#

# SEE https://aiohttp-session.readthedocs.io/en/latest/reference.html#abstract-storage
encrypted_cookie_sessions = EncryptedCookieStorage(
encrypted_cookie_sessions = SharedCookieEncryptedCookieStorage(
secret_key=settings.SESSION_SECRET_KEY.get_secret_value(),
cookie_name=DEFAULT_SESSION_COOKIE_NAME,
secure=settings.SESSION_COOKIE_SECURE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ def mocked_captcha_session(mocker: MockerFixture) -> MagicMock:
)


@pytest.mark.parametrize(
"user_role", [role for role in UserRole if role >= UserRole.USER]
)
async def test_check_auth(client: TestClient, logged_user: UserInfoDict):
assert client.app

response = await client.get("/v0/auth:check")
await assert_status(response, status.HTTP_200_OK)

response = await client.post("/v0/auth/logout")
await assert_status(response, status.HTTP_200_OK)

response = await client.get("/v0/auth:check")
await assert_status(response, status.HTTP_401_UNAUTHORIZED)


@pytest.mark.parametrize(
"user_role", [role for role in UserRole if role >= UserRole.USER]
)
Expand Down
Loading
Loading