Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6cd2a96
split webserver helpers
pcrespov Jun 26, 2025
47eb205
✨ [Tests] Enhance test fixtures with AsyncExitStack for better resour…
pcrespov Jun 26, 2025
159f697
✨ Add authentication setup to application initialization
pcrespov Jun 4, 2025
3865044
🎨 Refactor application initialization functions for improved clarity …
pcrespov Jun 4, 2025
41aad35
✨ Add application factory name to settings and update app factory logic
pcrespov Jun 4, 2025
8f023fb
moved test
pcrespov Jun 25, 2025
df6a393
✨ Update authentication check endpoint and improve documentation
pcrespov Jun 25, 2025
57014cc
@sanderegg review: PC this attribute does not exist and mypy does no…
pcrespov Jun 25, 2025
fdb2698
📝 Fix docstring formatting and ensure setup_db is called in setup_sec…
pcrespov Jun 25, 2025
8ddd971
✨ Implement login authentication module with routes for checking user…
pcrespov Jun 25, 2025
376697d
✨ Refactor authentication decorators: move login_required to login_au…
pcrespov Jun 25, 2025
9b7fa72
✨ Integrate setup_login_auth in login plugin and remove unnecessary s…
pcrespov Jun 25, 2025
12c3378
impving setup
pcrespov Jun 25, 2025
e295a7d
✨ Replace app_module_setup with ensure_single_setup for login_auth setup
pcrespov Jun 25, 2025
e751a67
integrating login_auth
pcrespov Jun 25, 2025
5b26e9f
fixes
pcrespov Jun 25, 2025
809c7d8
flip
pcrespov Jun 25, 2025
c64c8ca
services/webserver api version: 0.69.0 → 0.69.1
pcrespov Jun 25, 2025
ded9e5a
drafting test
pcrespov Jun 25, 2025
73b2900
rm dependency from storage
pcrespov Jun 25, 2025
347fb32
refactor: move logging setup to app_factory and update settings usage
pcrespov Jun 25, 2025
ba90727
adapting user fxitures
pcrespov Jun 25, 2025
144fa28
split login from users
pcrespov Jun 25, 2025
5c105d9
pylint
pcrespov Jun 25, 2025
2e3ae1a
pre
pcrespov Jun 25, 2025
314b9e2
fix: update login decorators to use new authentication module
pcrespov Jun 25, 2025
8f2cfb0
setup_db
pcrespov Jun 25, 2025
4126d43
fixes user_role enum
pcrespov Jun 25, 2025
9051308
fixes tests fixtures
pcrespov Jun 26, 2025
1e7eea2
fixes tests fixtures
pcrespov Jun 26, 2025
39c0b4c
fixing tests
pcrespov Jun 26, 2025
2a1670e
cleanup imports
pcrespov Jun 26, 2025
1c9e2b8
Merge branch 'master' into is7781/new-auth-app
pcrespov Jun 26, 2025
66e26d1
Merge branch 'master' into is7961/refactor-pytest-users
pcrespov Jun 26, 2025
4402941
bad imports
pcrespov Jun 26, 2025
3bfe084
fixes tests
pcrespov Jun 26, 2025
0e742b8
Merge branch 'is7961/refactor-pytest-users' into is7781/new-auth-app
pcrespov Jun 26, 2025
389cf18
OAS
pcrespov Jun 26, 2025
4fe1827
Merge branch 'master' into is7781/new-auth-app
pcrespov Jun 26, 2025
75c12d2
Merge branch 'master' into is7781/new-auth-app
pcrespov Jun 27, 2025
62aefc3
@sanderegg review:doc
pcrespov Jun 27, 2025
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
4 changes: 1 addition & 3 deletions api/specs/web-server/_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,17 +153,15 @@ async def logout(_body: LogoutBody):

@router.get(
"/auth:check",
operation_id="check_authentication",
status_code=status.HTTP_204_NO_CONTENT,
responses={
status.HTTP_401_UNAUTHORIZED: {
"model": EnvelopedError,
"description": "unauthorized reset due to invalid token code",
}
},
)
async def check_auth():
"""checks if user is authenticated in the platform"""
"""checks whether user request is authenticated"""


@router.post(
Expand Down
2 changes: 1 addition & 1 deletion services/web/server/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.69.0
0.69.1
2 changes: 1 addition & 1 deletion services/web/server/setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.69.0
current_version = 0.69.1
commit = True
message = services/webserver api version: {current_version} → {new_version}
tag = False
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ openapi: 3.1.0
info:
title: simcore-service-webserver
description: Main service with an interface (http-API & websockets) to the web front-end
version: 0.69.0
version: 0.69.1
servers:
- url: ''
description: webserver
Expand Down Expand Up @@ -238,13 +238,13 @@ paths:
tags:
- auth
summary: Check Auth
description: checks if user is authenticated in the platform
operationId: check_authentication
description: checks whether user request is authenticated
operationId: check_auth
responses:
'204':
description: Successful Response
'401':
description: unauthorized reset due to invalid token code
description: Unauthorized
content:
application/json:
schema:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .invitations.plugin import setup_invitations
from .licenses.plugin import setup_licenses
from .login.plugin import setup_login
from .login_auth.plugin import setup_login_auth
from .long_running_tasks import setup_long_running_tasks
from .notifications.plugin import setup_notifications
from .payments.plugin import setup_payments
Expand Down Expand Up @@ -169,6 +170,25 @@ def create_application() -> web.Application:
return app


def create_application_auth() -> web.Application:
app = create_safe_application()
setup_settings(app)
setup_rest(app)
setup_db(app)

setup_login_auth(app)

# NOTE: *last* events
app.on_startup.append(_welcome_banner)
app.on_shutdown.append(_finished_banner)

_logger.debug(
"Routes in application-auth: \n %s", pformat(app.router.named_resources())
)

return app


def run_service(app: web.Application, config: dict[str, Any]):
web.run_app(
app,
Expand All @@ -177,9 +197,3 @@ def run_service(app: web.Application, config: dict[str, Any]):
# this gets overriden by the gunicorn config in /docker/boot.sh
access_log_format='%a %t "%r" %s %b --- [%Dus] "%{Referer}i" "%{User-Agent}i"',
)


__all__: tuple[str, ...] = (
"create_application",
"run_service",
)
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from functools import cached_property
from typing import Annotated, Any, Final
from typing import Annotated, Any, Final, Literal

from aiohttp import web
from common_library.basic_types import DEFAULT_FACTORY
Expand Down Expand Up @@ -95,6 +95,13 @@ class ApplicationSettings(BaseApplicationSettings, MixinLoggingSettings):
Field(None, description="Stack name defined upon deploy (see main Makefile)"),
]

WEBSERVER_APP_FACTORY_NAME: Annotated[
Literal["WEBSERVER_FULL_APP_FACTORY", "WEBSERVER_AUTHZ_APP_FACTORY"],
Field(
description="Application factory to be lauched by the gunicorn server",
),
] = "WEBSERVER_FULL_APP_FACTORY"

WEBSERVER_DEV_FEATURES_ENABLED: Annotated[
bool,
Field(
Expand Down
37 changes: 22 additions & 15 deletions services/web/server/src/simcore_service_webserver/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" Application's command line .
"""Application's command line .

Why does this file exist, and why not put this in __main__?

Expand All @@ -15,13 +15,12 @@

import logging
import os
from typing import Final
from typing import Annotated, Final

import typer
from aiohttp import web
from common_library.json_serialization import json_dumps
from settings_library.utils_cli import create_settings_command
from typing_extensions import Annotated

from .application_settings import ApplicationSettings
from .login import cli as login_cli
Expand All @@ -42,7 +41,6 @@ def _setup_app_from_settings(
# NOTE: keeping imports here to reduce CLI load time
from .application import create_application
from .application_settings_utils import convert_to_app_config
from .log import setup_logging

# NOTE: By having an equivalent config allows us
# to keep some of the code from the previous
Expand All @@ -51,30 +49,39 @@ def _setup_app_from_settings(
# given configs and changing those would not have
# a meaningful RoI.
config = convert_to_app_config(settings)

setup_logging(
level=settings.log_level,
slow_duration=settings.AIODEBUG_SLOW_DURATION_SECS,
log_format_local_dev_enabled=settings.WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED,
logger_filter_mapping=settings.WEBSERVER_LOG_FILTER_MAPPING,
tracing_settings=settings.WEBSERVER_TRACING,
)

app = create_application()
return (app, config)


async def app_factory() -> web.Application:
"""Created to launch app from gunicorn (see docker/boot.sh)"""
from .application import create_application_auth
from .log import setup_logging

app_settings = ApplicationSettings.create_from_envs()
assert app_settings.SC_BUILD_TARGET # nosec

_logger.info(
"Application settings: %s",
json_dumps(app_settings, indent=2, sort_keys=True),
)

app, _ = _setup_app_from_settings(app_settings)
_logger.info(
"Using application factory: %s", app_settings.WEBSERVER_APP_FACTORY_NAME
)

setup_logging(
level=app_settings.log_level,
slow_duration=app_settings.AIODEBUG_SLOW_DURATION_SECS,
log_format_local_dev_enabled=app_settings.WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED,
logger_filter_mapping=app_settings.WEBSERVER_LOG_FILTER_MAPPING,
tracing_settings=app_settings.WEBSERVER_TRACING,
)

if app_settings.WEBSERVER_APP_FACTORY_NAME == "WEBSERVER_AUTHZ_APP_FACTORY":

app = create_application_auth()
else:
app, _ = _setup_app_from_settings(app_settings)

return app

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,19 +287,3 @@ async def logout(request: web.Request) -> web.Response:
await security_web.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 database 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 endpoint or rely on Redis

assert request # nosec

return web.json_response(status=status.HTTP_204_NO_CONTENT)
Original file line number Diff line number Diff line change
@@ -1,73 +1,7 @@
import functools
import inspect
from typing import cast
from ..login_auth.decorators import get_user_id, login_required

from aiohttp import web
from models_library.users import UserID
from servicelib.aiohttp.typing_extension import HandlerAnyReturn
from servicelib.request_keys import RQT_USERID_KEY

from ..products import products_web
from ..security import security_web


def login_required(handler: HandlerAnyReturn) -> HandlerAnyReturn:
"""Decorator that restrict access only for authorized users with permissions to access a given product

- User is considered authorized if check_authorized(request) raises no exception
- If authorized, it injects user_id in request[RQT_USERID_KEY]
- Use this decorator instead of aiohttp_security.api.login_required!

WARNING: Add always @router. decorator FIRST, e.g.

@router.get("/foo")
@login_required
async def get_foo(request: web.Request):
...

and NOT as

@login_required
@router.get("/foo")
async def get_foo(request: web.Request):
...

since the latter will register in `router` get_foo **without** `login_required`
"""
assert set(inspect.signature(handler).parameters.values()) == { # nosec
inspect.Parameter(
name="request",
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
annotation=web.Request,
)
}, f"Expected {handler.__name__} with request as signature, got {handler.__annotations__}"

@functools.wraps(handler)
async def _wrapper(request: web.Request):
"""
Raises:
HTTPUnauthorized: if unauthorized user
HTTPForbidden: if user not allowed in product
"""
# WARNING: note that check_authorized is patched in some tests.
# Careful when changing the function signature
user_id = await security_web.check_user_authorized(request)
product_name = products_web.get_product_name(request)

await security_web.check_user_permission(
request,
security_web.PERMISSION_PRODUCT_LOGIN_KEY,
context=security_web.AuthContextDict(
product_name=product_name,
authorized_uid=user_id,
),
)

request[RQT_USERID_KEY] = user_id
return await handler(request)

return _wrapper


def get_user_id(request: web.Request) -> UserID:
return cast(UserID, request[RQT_USERID_KEY])
__all__: tuple[str, ...] = (
"get_user_id",
"login_required",
)
# nopycln: file
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ..email.plugin import setup_email
from ..email.settings import get_plugin_settings as get_email_plugin_settings
from ..invitations.plugin import setup_invitations
from ..login_auth.plugin import setup_login_auth
from ..products import products_service
from ..products.models import ProductName
from ..products.plugin import setup_products
Expand Down Expand Up @@ -65,13 +66,13 @@ async def _setup_login_storage_ctx(app: web.Application):
yield # ----------------


@ensure_single_setup(f"{__name__}.setup_login_storage", logger=log)
@ensure_single_setup(f"{__name__}.storage", logger=log)
def setup_login_storage(app: web.Application):
if _setup_login_storage_ctx not in app.cleanup_ctx:
app.cleanup_ctx.append(_setup_login_storage_ctx)


@ensure_single_setup(f"{__name__}._setup_login_options", logger=log)
@ensure_single_setup(f"{__name__}.login_options", logger=log)
def _setup_login_options(app: web.Application):
settings: SMTPSettings = get_email_plugin_settings(app)

Expand Down Expand Up @@ -145,6 +146,8 @@ def setup_login(app: web.Application):
# routes

app.router.add_routes(auth.routes)
setup_login_auth(app)

app.router.add_routes(confirmation.routes)
app.router.add_routes(registration.routes)
app.router.add_routes(preregistration.routes)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import logging

from aiohttp import web
from aiohttp.web import RouteTableDef
from servicelib.aiohttp import status

from .._meta import API_VTAG
from .decorators import login_required

_logger = logging.getLogger(__name__)


routes = RouteTableDef()


@routes.get(f"/{API_VTAG}/auth:check", name="check_auth")
@login_required
async def check_auth(request: web.Request) -> web.Response:
"""Lightweight endpoint for checking if users are authenticated & authorized to this product

Used primarily by Traefik auth middleware to verify session cookies
SEE https://doc.traefik.io/traefik/middlewares/http/forwardauth
"""
# NOTE: for future development
# if database 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 endpoint or rely on Redis
assert request # nosec

return web.json_response(status=status.HTTP_204_NO_CONTENT)
Loading
Loading