diff --git a/asab/contextvars.py b/asab/contextvars.py index 0ab01daab..4cb126527 100644 --- a/asab/contextvars.py +++ b/asab/contextvars.py @@ -1,6 +1,10 @@ import contextvars +# Contains tenant ID string Tenant = contextvars.ContextVar("Tenant") +# Contains asab.web.auth.Authorization object +Authz = contextvars.ContextVar("Authz") + # Contains aiohttp.web.Request Request = contextvars.ContextVar("Request") diff --git a/asab/web/auth/__init__.py b/asab/web/auth/__init__.py index 0784d282e..104493cdd 100644 --- a/asab/web/auth/__init__.py +++ b/asab/web/auth/__init__.py @@ -1,8 +1,10 @@ from .decorator import require, noauth from .service import AuthService +from .authorization import Authorization __all__ = ( "AuthService", + "Authorization", "require", "noauth", ) diff --git a/asab/web/auth/authorization.py b/asab/web/auth/authorization.py new file mode 100644 index 000000000..35a1d6cb3 --- /dev/null +++ b/asab/web/auth/authorization.py @@ -0,0 +1,209 @@ +import datetime +import typing +import logging + +from ...exceptions import AccessDeniedError, NotAuthenticatedError +from ...contextvars import Tenant + +L = logging.getLogger(__name__) + + +SUPERUSER_RESOURCE_ID = "authz:superuser" + + +class Authorization: + """ + Contains authentication and authorization details, provides methods for checking and enforcing access control. + + Requires that AuthService is initialized and enabled. + """ + def __init__(self, auth_service, userinfo: dict): + self.AuthService = auth_service + if not self.AuthService.is_enabled(): + raise ValueError("Cannot create Authorization when AuthService is disabled.") + + # Userinfo should not be accessed directly + self._UserInfo = userinfo or {} + + self.CredentialsId = self._UserInfo.get("sub") + self.Username = self._UserInfo.get("preferred_username") or self._UserInfo.get("username") + self.Email = self._UserInfo.get("email") + self.Phone = self._UserInfo.get("phone") + + self.Issuer = self._UserInfo.get("iss") # Who issued the authorization + self.AuthorizedParty = self._UserInfo.get("azp") # What party (application) is authorized + self.IssuedAt = datetime.datetime.fromtimestamp(int(self._UserInfo["iat"]), datetime.timezone.utc) + self.Expiration = datetime.datetime.fromtimestamp(int(self._UserInfo["exp"]), datetime.timezone.utc) + + + def __repr__(self): + return "".format( + "SUPERUSER, " if self.has_superuser_access() else "", + self.CredentialsId, + self.AuthorizedParty, + self.IssuedAt.isoformat(), + self.Expiration.isoformat(), + ) + + + def is_valid(self): + """ + Check that the authorization is not expired. + """ + return datetime.datetime.now(datetime.timezone.utc) < self.Expiration + + + def has_superuser_access(self) -> bool: + """ + Check whether the agent is a superuser. + + :return: Is the agent a superuser? + """ + self.require_valid() + return is_superuser(self._UserInfo) + + + def has_resource_access(self, *resources: typing.Iterable[str]) -> bool: + """ + Check whether the agent is authorized to access requested resources. + + :param resources: List of resource IDs whose authorization is requested. + :return: Is resource access authorized? + """ + self.require_valid() + return has_resource_access(self._UserInfo, resources, tenant=Tenant.get(None)) + + + def has_tenant_access(self) -> bool: + """ + Check whether the agent has access to the tenant in context. + + :return: Is tenant access authorized? + """ + self.require_valid() + + try: + tenant = Tenant.get() + except LookupError as e: + raise ValueError("No tenant in context.") from e + + return has_tenant_access(self._UserInfo, tenant) + + + def require_valid(self): + """ + Ensure that the authorization is not expired. + """ + if not self.is_valid(): + L.warning("Authorization expired.", struct_data={ + "cid": self.CredentialsId, "azp": self.AuthorizedParty, "exp": self.Expiration.isoformat()}) + raise NotAuthenticatedError() + + + def require_superuser_access(self): + """ + Assert that the agent has superuser access. + """ + if not self.has_superuser_access(): + L.warning("Superuser authorization required.", struct_data={ + "cid": self.CredentialsId, "azp": self.AuthorizedParty}) + raise AccessDeniedError() + + + def require_resource_access(self, *resources: typing.Iterable[str]): + """ + Assert that the agent is authorized to access the required resources. + + :param resources: List of resource IDs whose authorization is required. + """ + if not self.has_resource_access(*resources): + L.warning("Resource authorization required.", struct_data={ + "resource": resources, "cid": self.CredentialsId, "azp": self.AuthorizedParty}) + raise AccessDeniedError() + + + def require_tenant_access(self): + """ + Assert that the agent is authorized to access the tenant in the context. + """ + if not self.has_tenant_access(): + L.warning("Tenant authorization required.", struct_data={ + "tenant": Tenant.get(), "cid": self.CredentialsId, "azp": self.AuthorizedParty}) + raise AccessDeniedError() + + + def authorized_resources(self) -> typing.Optional[typing.Set[str]]: + """ + Return the set of authorized resources. + + NOTE: If possible, use methods has_resource_access(resource_id) and has_superuser_access() instead of inspecting + the set of resources directly. + + :return: Set of authorized resources. + """ + self.require_valid() + return get_authorized_resources(self._UserInfo, Tenant.get(None)) + + + def user_info(self) -> typing.Dict[str, typing.Any]: + """ + Return OpenID Connect UserInfo. + + NOTE: If possible, use Authz attributes (CredentialsId, Username etc.) instead of inspecting the user info + dictionary directly. + + :return: User info + """ + self.require_valid() + return self._UserInfo + + +def is_superuser(userinfo: typing.Mapping) -> bool: + """ + Check if the superuser resource is present in the authorized resource list. + """ + return SUPERUSER_RESOURCE_ID in get_authorized_resources(userinfo, tenant=None) + + +def has_resource_access( + userinfo: typing.Mapping, + resources: typing.Iterable, + tenant: typing.Union[str, None], +) -> bool: + """ + Check if the requested resources or the superuser resource are present in the authorized resource list. + """ + if is_superuser(userinfo): + return True + + authorized_resources = get_authorized_resources(userinfo, tenant) + for resource in resources: + if resource not in authorized_resources: + return False + + return True + + +def has_tenant_access(userinfo: typing.Mapping, tenant: str) -> bool: + """ + Check the agent's userinfo to see if they are authorized to access a tenant. + If the agent has superuser access, tenant access is always implicitly granted. + """ + if tenant == "*": + raise ValueError("Invalid tenant name: '*'") + if is_superuser(userinfo): + return True + if tenant in userinfo.get("resources", {}): + return True + return False + + +def get_authorized_resources(userinfo: typing.Mapping, tenant: typing.Union[str, None]) -> typing.Set[str]: + """ + Extract resources authorized within given tenant (or globally, if tenant is None). + + :param userinfo: + :param tenant: + :return: Set of authorized resources. + """ + return set(userinfo.get("resources", {}).get(tenant if tenant is not None else "*", [])) diff --git a/asab/web/auth/decorator.py b/asab/web/auth/decorator.py index 709d6fc9f..0f00f772c 100644 --- a/asab/web/auth/decorator.py +++ b/asab/web/auth/decorator.py @@ -3,6 +3,7 @@ import inspect from ...exceptions import AccessDeniedError +from ...contextvars import Authz # @@ -13,16 +14,16 @@ def require(*resources): """ - Specify resources required for endpoint access. - Requests without these resources result in HTTP 403 response. + Require that the request have authorized access to one or more resources. + Requests without these resources result in AccessDeniedError and consequently in an HTTP 403 response. Args: - resources (Iterable): Resources required to access the decorated method. + resources (Iterable): Resources whose authorization is required. Examples: ```python - @asab.web.authz.require("my-app:token:generate") + @asab.web.auth.require("my-app:token:generate") async def generate_token(self, request): data = await self.service.generate_token() return asab.web.rest.json_response(request, data) @@ -32,16 +33,12 @@ def decorator_require(handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): - request = args[-1] - - if not hasattr(request, "has_resource_access"): - raise Exception( - "Cannot check resource access. Make sure that AuthService is installed and that " - "the handler method does not use both the '@noauth' and the '@require' decorators at once.") - - if not request.has_resource_access(*resources): + authz = Authz.get() + if authz is None: raise AccessDeniedError() + authz.require_resource_access(*resources) + return await handler(*args, **kwargs) return wrapper @@ -52,12 +49,11 @@ async def wrapper(*args, **kwargs): def noauth(handler): """ Exempt the decorated handler from authentication and authorization. - The `tenant`, `user_info` and `resources` arguments are not available in the handler. Examples: ```python - @asab.web.authz.noauth + @asab.web.auth.noauth async def get_public_info(self, request): data = await self.service.get_public_info() return asab.web.rest.json_response(request, data) @@ -65,7 +61,7 @@ async def get_public_info(self, request): """ argspec = inspect.getfullargspec(handler) args = set(argspec.kwonlyargs).union(argspec.args) - for arg in ("tenant", "user_info", "resources"): + for arg in ("tenant", "user_info", "resources", "authz"): if arg in args: raise Exception( "{}(): Handler with @noauth cannot have {!r} in its arguments.".format(handler.__qualname__, arg)) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 18ad506b4..a6f16deb5 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -6,20 +6,23 @@ import json import logging import os.path -import typing import time import enum +import typing import aiohttp import aiohttp.web import aiohttp.client_exceptions +from ... import LogObsolete from ...abc.service import Service from ...config import Config from ...exceptions import NotAuthenticatedError, AccessDeniedError from ...api.discovery import NotDiscoveredError from ...utils import string_to_boolean -from ...contextvars import Tenant +from ...contextvars import Tenant, Authz +from ..websocket import WebSocketFactory +from .authorization import Authorization try: import jwcrypto.jwk @@ -81,23 +84,6 @@ class AuthMode(enum.Enum): class AuthService(Service): """ Provides authentication and authorization of incoming requests. - - Configuration: - Configuration section: auth - Configuration options: - public_keys_url: - - default: "" - - URL containing the authorization server's public JWKey set (usually found at "/.well-known/jwks.json") - enabled: - - default: "yes" - - options: "yes", "no", "mocked" - - Switch authentication and authorization on, off or activate mock mode. - - In MOCK MODE - - no authorization server is needed, - - all incoming requests are mock-authorized with pre-defined user info, - - custom mock user info can supplied in a JSON file. - mock_user_info_path: - - default: "/conf/mock-userinfo.json" """ _PUBLIC_KEYS_URL_DEFAULT = "http://localhost:3081/.well-known/jwks.json" @@ -141,6 +127,17 @@ def __init__(self, app, service_name="asab.AuthService"): if self.Mode == AuthMode.ENABLED: self.App.TaskService.schedule(self._fetch_public_keys_if_needed()) + self.Authorizations: typing.Dict[typing.Tuple[str, str], Authorization] = {} + self.App.PubSub.subscribe("Application.housekeeping!", self._delete_invalid_authorizations) + + # Try to auto-install authorization middleware + self._try_auto_install() + + + async def initialize(self, app): + self._check_if_installed() + + def _prepare_mock_user_info(self): # Load custom user info mock_user_info_path = Config.get("auth", "mock_user_info_path") @@ -173,12 +170,26 @@ def is_enabled(self) -> bool: def install(self, web_container): """ - Apply authorization to all web handlers in a web container, according to their arguments and path parameters. + Apply authorization to all web handlers in a web container. :param web_container: Web container to be protected by authorization. :type web_container: asab.web.WebContainer """ - # TODO: Call this automatically if there is only one container + web_service = self.App.get_service("asab.WebService") + + # Check that the middleware has not been installed yet + for middleware in web_container.WebApp.on_startup: + if middleware == self._wrap_handlers: + if len(web_service.Containers) == 1: + L.warning( + "WebContainer has authorization middleware installed already. " + "You don't need to call `AuthService.install()` in applications with a single WebContainer; " + "it is called automatically at init time." + ) + else: + L.warning("WebContainer has authorization middleware installed already.") + return + web_container.WebApp.on_startup.append(self._wrap_handlers) @@ -195,84 +206,80 @@ def is_ready(self): return True - async def get_userinfo_from_id_token(self, bearer_token): - """ - Parse the bearer ID token and extract user info. + async def build_authorization(self, id_token: str) -> Authorization: """ - if not self.is_ready(): - # Try to load the public keys again - if not self.TrustedPublicKeys["keys"]: - await self._fetch_public_keys_if_needed() - if not self.is_ready(): - L.error("Cannot authenticate request: Failed to load authorization server's public keys.") - raise aiohttp.web.HTTPUnauthorized() + Build authorization from ID token string. - try: - return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) - except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey): - # Authz server keys may have changed. Try to reload them. - await self._fetch_public_keys_if_needed() + :param id_token: Base64-encoded JWToken from Authorization header + :return: Valid asab.web.auth.Authorization object + """ + if not self.is_enabled(): + raise ValueError("Cannot build Authorization when AuthService is disabled.") - try: - return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) - except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey) as e: - L.error("Cannot authenticate request: {}".format(str(e))) - raise NotAuthenticatedError() + # Try if the object already exists + authz = self.Authorizations.get(id_token) + if authz is not None: + try: + authz.require_valid() + except NotAuthenticatedError as e: + del self.Authorizations[id_token] + raise e + return authz + # Create a new Authorization object and store it + if self.Mode == AuthMode.MOCK: + assert id_token == "MOCK" + authz = Authorization(self, self.MockUserInfo) + else: + userinfo = await self._get_userinfo_from_id_token(id_token) + authz = Authorization(self, userinfo) - def get_authorized_tenant(self, request) -> typing.Optional[str]: - """ - Get the request's authorized tenant. - """ - if hasattr(request, "_AuthorizedTenants"): - for tenant in request._AuthorizedTenants: - # Return the first authorized tenant - return tenant - return None + self.Authorizations[id_token] = authz + return authz - def has_superuser_access(self, authorized_resources: typing.Iterable) -> bool: + async def _delete_invalid_authorizations(self): """ - Check if the superuser resource is present in the authorized resource list. + Check for expired Authorization objects and delete them """ - if self.Mode == AuthMode.DISABLED: - return True - return SUPERUSER_RESOURCE in authorized_resources + # Find expired + expired = [] + for key, authz in self.Authorizations.items(): + if not authz.is_valid(): + expired.append(key) + + # Delete expired + for key in expired: + del self.Authorizations[key] - def has_resource_access(self, authorized_resources: typing.Iterable, required_resources: typing.Iterable) -> bool: + def get_bearer_token_from_authorization_header(self, request): """ - Check if the requested resources or the superuser resource are present in the authorized resource list. + Validate the Authorizetion header and extract the Bearer token value """ - if self.Mode == AuthMode.DISABLED: - return True - if self.has_superuser_access(authorized_resources): - return True - for resource in required_resources: - if resource not in authorized_resources: - return False - return True + if self.Mode == AuthMode.MOCK: + return "MOCK" + authorization_header = request.headers.get(aiohttp.hdrs.AUTHORIZATION) + if authorization_header is None: + L.warning("No Authorization header.") + raise aiohttp.web.HTTPUnauthorized() + try: + auth_type, token_value = authorization_header.split(" ", 1) + except ValueError: + L.warning("Cannot parse Authorization header.") + raise aiohttp.web.HTTPBadRequest() + if auth_type != "Bearer": + L.warning("Unsupported Authorization header type: {!r}".format(auth_type)) + raise aiohttp.web.HTTPUnauthorized() + return token_value - def has_tenant_access( - self, authorized_resources: typing.Iterable, authorized_tenants: typing.Iterable, tenant: str - ) -> bool: - """ - Check if the request is authorized to access a tenant. - If the request has superuser access, tenant access is always implicitly granted. - """ - if self.Mode == AuthMode.DISABLED: - return True - if self.has_superuser_access(authorized_resources): - return True - if tenant in authorized_tenants: - return True - return False async def _fetch_public_keys_if_needed(self, *args, **kwargs): """ Check if public keys have been fetched from the authorization server and fetch them if not yet. """ + # TODO: Refactor into Key Providers # Add internal shared auth key if self.DiscoveryService is not None: if self.DiscoveryService.InternalAuthKey is not None: @@ -294,6 +301,7 @@ async def _fetch_public_keys_if_needed(self, *args, **kwargs): # Public keys have been fetched recently return + async def fetch_keys(session): try: async with session.get(self.PublicKeysUrl) as response: @@ -357,49 +365,32 @@ async def fetch_keys(session): L.debug("Public key loaded.", struct_data={"url": self.PublicKeysUrl}) - def _authenticate_request(self, handler): + def _authorize_request(self, handler): """ Authenticate the request by the JWT ID token in the Authorization header. - Extract the token claims into request attributes so that they can be used for authorization checks. + Extract the token claims into Authorization context so that they can be used for authorization checks. """ @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - if self.Mode == AuthMode.DISABLED: - user_info = None - elif self.Mode == AuthMode.MOCK: - user_info = self.MockUserInfo - else: - # Extract user info from the request Authorization header - bearer_token = _get_bearer_token(request) - user_info = await self.get_userinfo_from_id_token(bearer_token) - - # Add userinfo, tenants and global resources to the request - if self.Mode != AuthMode.DISABLED: - assert user_info is not None - request._UserInfo = user_info - resource_dict = request._UserInfo["resources"] - request._AuthorizedResources = frozenset(resource_dict.get("*", [])) - request._AuthorizedTenants = frozenset(t for t in resource_dict.keys() if t != "*") - else: - request._UserInfo = None - request._AuthorizedResources = None - request._AuthorizedTenants = None - # Add access control methods to the request - def has_resource_access(*required_resources: list) -> bool: - return self.has_resource_access(request._AuthorizedResources, required_resources) - request.has_resource_access = has_resource_access + if not self.is_enabled(): + return await handler(*args, **kwargs) - def has_tenant_access(tenant: str) -> bool: - return self.has_tenant_access(request._AuthorizedResources, request._AuthorizedTenants, tenant) - request.has_tenant_access = has_tenant_access + bearer_token = self.get_bearer_token_from_authorization_header(request) + authz = await self.build_authorization(bearer_token) - def has_superuser_access() -> bool: - return self.has_superuser_access(request._AuthorizedResources) - request.has_superuser_access = has_superuser_access + # Authorize tenant context + tenant = Tenant.get(None) + if tenant is not None: + authz.require_tenant_access() + + authz_ctx = Authz.set(authz) + try: + return await handler(*args, **kwargs) + finally: + Authz.reset(authz_ctx) - return await handler(*args, **kwargs) return wrapper @@ -430,7 +421,7 @@ def _wrap_handler(self, route): route_info = route.get_info() tenant_in_path = "formatter" in route_info and "{tenant}" in route_info["formatter"] - # Extract the actual handler method for signature checks + # Extract the actual unwrapped handler method for signature inspection handler_method = route.handler while hasattr(handler_method, "__wrapped__"): # While loop unwraps handlers wrapped in multiple decorators. @@ -440,129 +431,125 @@ def _wrap_handler(self, route): if hasattr(handler_method, "__func__"): handler_method = handler_method.__func__ + is_websocket = isinstance(handler_method, WebSocketFactory) + if hasattr(handler_method, "NoAuth"): return argspec = inspect.getfullargspec(handler_method) args = set(argspec.kwonlyargs).union(argspec.args) - # Extract the whole handler for wrapping + # Extract the whole handler including its existing decorators and wrappers handler = route.handler - # Apply the decorators in reverse order (the last applied wrapper affects the request first) + # Apply the decorators IN REVERSE ORDER (the last applied wrapper affects the request first) + + # 3) Pass authorization attributes to handler method if "resources" in args: - handler = _add_resources(handler) + LogObsolete.warning( + "The 'resources' argument is deprecated. " + "Use the access-checking methods of asab.contextvars.Authz instead.", + struct_data={"handler": handler.__qualname__, "eol": "2025-03-01"}, + ) + handler = _pass_resources(handler) if "user_info" in args: - handler = _add_user_info(handler) + LogObsolete.warning( + "The 'user_info' argument is deprecated. " + "Use the Authorization object in asab.contextvars.Authz instead.", + struct_data={"handler": handler.__qualname__, "eol": "2025-03-01"}, + ) + handler = _pass_user_info(handler) if "tenant" in args: - # TODO: Deprecate tenant ID in path and query, always use X-Tenant header instead. - if tenant_in_path: - handler = self._add_tenant_from_path(handler) - else: - handler = self._add_tenant_from_query(handler) + handler = _pass_tenant(handler) + if "authz" in args: + handler = _pass_authz(handler) - handler = self._set_tenant_context_from_header(handler) - - handler = self._authenticate_request(handler) - route._handler = handler + # 2) Authenticate and authorize request, authorize tenant from context, set Authorization context + handler = self._authorize_request(handler) + # 1.5) Set tenant context from obsolete locations (no authorization yet) + # TODO: Deprecated. Ignore tenant in path and query, always use request headers instead. + if tenant_in_path: + handler = _set_tenant_context_from_url_path(handler) + else: + handler = _set_tenant_context_from_url_query(handler) - def _authorize_tenant_request(self, request, tenant): - """ - Check access to requested tenant and add tenant resources to the request - """ - # Check if tenant access is authorized - if not request.has_tenant_access(tenant): - L.warning("Tenant not authorized.", struct_data={"tenant": tenant, "sub": request._UserInfo.get("sub")}) - raise AccessDeniedError() + # 1) Set tenant context (no authorization yet) + # TODO: This should be eventually done by TenantService + if is_websocket: + handler = _set_tenant_context_from_sec_websocket_protocol_header(handler) + else: + handler = _set_tenant_context_from_x_tenant_header(handler) - # Extend globally granted resources with tenant-granted resources - request._AuthorizedResources = set(request._AuthorizedResources.union( - request._UserInfo["resources"].get(tenant, []))) + route._handler = handler - def _set_tenant_context_from_header(self, handler): + async def _get_userinfo_from_id_token(self, bearer_token): """ - Extract tenant from request path and authorize it + Parse the bearer ID token and extract user info. """ + if not self.is_ready(): + # Try to load the public keys again + if not self.TrustedPublicKeys["keys"]: + await self._fetch_public_keys_if_needed() + if not self.is_ready(): + L.error("Cannot authenticate request: Failed to load authorization server's public keys.") + raise aiohttp.web.HTTPUnauthorized() - @functools.wraps(handler) - async def wrapper(*args, **kwargs): - request = args[-1] - - if request.headers.get("Upgrade") == "websocket": - # Get tenant from Sec-Websocket-Protocol header for websocket requests - x = request.headers.get("Sec-Websocket-Protocol", "") - for i in x.split(", "): - i = i.strip() - if i.startswith("tenant_"): - tenant = i[7:] - break - else: - tenant = None - else: - # Get tenant from X-Tenant header for HTTP requests - tenant = request.headers.get("X-Tenant") - - if tenant is not None and self.Mode != AuthMode.DISABLED: - assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity - self._authorize_tenant_request(request, tenant) - - tenant_ctx = Tenant.set(tenant) - try: - response = await handler(*args, **kwargs) - finally: - Tenant.reset(tenant_ctx) - return response - - return wrapper + try: + return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) + except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey): + # Authz server keys may have changed. Try to reload them. + await self._fetch_public_keys_if_needed() + try: + return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) + except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey) as e: + L.error("Cannot authenticate request: {}".format(str(e))) + raise NotAuthenticatedError() - def _add_tenant_from_path(self, handler): + def _check_if_installed(self): """ - Extract tenant from request path and authorize it + Check if there is at least one web container with authorization installed """ + web_service = self.App.get_service("asab.WebService") + if web_service is None or len(web_service.Containers) == 0: + L.warning("Authorization is not installed: There are no web containers.") + return - @functools.wraps(handler) - async def wrapper(*args, **kwargs): - request = args[-1] - tenant_from_header = Tenant.get(None) - tenant = request.match_info["tenant"] - if tenant_from_header and tenant != tenant_from_header: - L.warning("Tenant in path differs from tenant in X-Tenant header.", struct_data={ - "path": tenant, "header": tenant_from_header}) - - if self.Mode != AuthMode.DISABLED: - self._authorize_tenant_request(request, tenant) + for web_container in web_service.Containers.values(): + for middleware in web_container.WebApp.on_startup: + if middleware == self._wrap_handlers: + # Container has authorization installed + break + else: + continue - return await handler(*args, tenant=tenant, **kwargs) + # Container has authorization installed + break - return wrapper + else: + L.warning( + "Authorization is not installed in any web container. " + "In applications with more than one WebContainer there is no automatic installation; " + "you have to call `AuthService.install(web_container)` explicitly." + ) + return - def _add_tenant_from_query(self, handler): + def _try_auto_install(self): """ - Extract tenant from request query and authorize it + If there is exactly one web container, install authorization middleware on it. """ + web_service = self.App.get_service("asab.WebService") + if web_service is None: + return + if len(web_service.Containers) != 1: + return + web_container = web_service.WebContainer - @functools.wraps(handler) - async def wrapper(*args, **kwargs): - request = args[-1] - tenant_from_header = Tenant.get(None) - if "tenant" not in request.query: - return await handler(*args, tenant=tenant_from_header, **kwargs) - - tenant = request.query["tenant"] - if tenant_from_header and tenant != tenant_from_header: - L.warning("Tenant in query differs from tenant in X-Tenant header.", struct_data={ - "query": tenant, "header": tenant_from_header}) - - if self.Mode != AuthMode.DISABLED: - self._authorize_tenant_request(request, tenant) - - return await handler(*args, tenant=tenant, **kwargs) - - return wrapper + self.install(web_container) + L.info("WebContainer authorization installed automatically.") def _get_id_token_claims(bearer_token: str, auth_server_public_key): @@ -622,42 +609,173 @@ def _get_id_token_claims_without_verification(bearer_token: str): return claims -def _get_bearer_token(request): +def _pass_user_info(handler): """ - Validate the Authorizetion header and extract the Bearer token value + Add user info to the handler arguments """ - authorization_header = request.headers.get(aiohttp.hdrs.AUTHORIZATION) - if authorization_header is None: - L.warning("No Authorization header.") - raise aiohttp.web.HTTPUnauthorized() - try: - auth_type, token_value = authorization_header.split(" ", 1) - except ValueError: - L.warning("Cannot parse Authorization header.") - raise aiohttp.web.HTTPBadRequest() - if auth_type != "Bearer": - L.warning("Unsupported Authorization header type: {!r}".format(auth_type)) - raise aiohttp.web.HTTPUnauthorized() - return token_value + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + authz = Authz.get(None) + return await handler(*args, user_info=authz.user_info() if authz is not None else None, **kwargs) + return wrapper -def _add_user_info(handler): +def _pass_resources(handler): """ - Add user info to the handler arguments + Add resources to the handler arguments + """ + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + authz = Authz.get(None) + return await handler(*args, resources=authz.authorized_resources() if authz is not None else None, **kwargs) + return wrapper + + +def _pass_tenant(handler): + """ + Add tenant to the handler arguments + """ + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + return await handler(*args, tenant=Tenant.get(None), **kwargs) + return wrapper + + +def _pass_authz(handler): + """ + Add Auhorization object to the handler arguments """ + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + authz = Authz.get(None) + return await handler(*args, authz=authz, **kwargs) + return wrapper + + +def _set_tenant_context_from_x_tenant_header(handler): + """ + Extract tenant from X-Tenant header and add it to context + """ + def get_tenant_from_header(request) -> str: + # Get tenant from X-Tenant header for HTTP requests + return request.headers.get("X-Tenant") + @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - return await handler(*args, user_info=request._UserInfo, **kwargs) + tenant = get_tenant_from_header(request) + + if tenant is None: + response = await handler(*args, **kwargs) + else: + assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity + tenant_ctx = Tenant.set(tenant) + try: + response = await handler(*args, **kwargs) + finally: + Tenant.reset(tenant_ctx) + + return response + return wrapper -def _add_resources(handler): +def _set_tenant_context_from_sec_websocket_protocol_header(handler): """ - Add resources to the handler arguments + Extract tenant from Sec-Websocket-Protocol header and add it to context """ + def get_tenant_from_header(request) -> str: + # Get tenant from Sec-Websocket-Protocol header for websocket requests + protocols = request.headers.get("Sec-Websocket-Protocol", "") + for protocol in protocols.split(", "): + protocol = protocol.strip() + if protocol.startswith("tenant_"): + return protocol[7:] + else: + return None + @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - return await handler(*args, resources=request._AuthorizedResources, **kwargs) + tenant = get_tenant_from_header(request) + + if tenant is None: + response = await handler(*args, **kwargs) + else: + assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity + tenant_ctx = Tenant.set(tenant) + try: + response = await handler(*args, **kwargs) + finally: + Tenant.reset(tenant_ctx) + + return response + + return wrapper + + +def _set_tenant_context_from_url_query(handler): + """ + Extract tenant from request query and add it to context + """ + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + request = args[-1] + header_tenant = Tenant.get(None) + tenant = request.query.get("tenant") + + if tenant is None: + # No tenant in query + response = await handler(*args, **kwargs) + elif header_tenant is not None: + # Tenant from header must not be overwritten by a different tenant in query! + if tenant != header_tenant: + L.error("Tenant from URL query does not match tenant from header.", struct_data={ + "header_tenant": header_tenant, "query_tenant": tenant}) + raise AccessDeniedError() + # Tenant in query matches tenant in header + response = await handler(*args, **kwargs) + else: + # No tenant in header, only in query + assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity + tenant_ctx = Tenant.set(tenant) + try: + response = await handler(*args, **kwargs) + finally: + Tenant.reset(tenant_ctx) + + return response + + return wrapper + + +def _set_tenant_context_from_url_path(handler): + """ + Extract tenant from request URL path and add it to context + """ + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + request = args[-1] + header_tenant = Tenant.get(None) + tenant = request.match_info.get("tenant") + + if header_tenant is not None: + # Tenant from header must not be overwritten by a different tenant in path! + if tenant != header_tenant: + L.error("Tenant from URL path does not match tenant from header.", struct_data={ + "header_tenant": header_tenant, "path_tenant": tenant}) + raise AccessDeniedError() + # Tenant in path matches tenant in header + response = await handler(*args, **kwargs) + else: + # No tenant in header, only in path + assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity + tenant_ctx = Tenant.set(tenant) + try: + response = await handler(*args, **kwargs) + finally: + Tenant.reset(tenant_ctx) + + return response + return wrapper diff --git a/docs/reference/services/web/authorization_multitenancy.md b/docs/reference/services/web/authorization_multitenancy.md index c4fc259bd..a80c07d86 100644 --- a/docs/reference/services/web/authorization_multitenancy.md +++ b/docs/reference/services/web/authorization_multitenancy.md @@ -15,64 +15,89 @@ It works best with TeskaLabs [Seacat Auth](https://github.com/TeskaLabs/seacat-a authorization server (See [its documentation](https://docs.teskalabs.com/seacat-auth/getting-started/quick-start) for setup instructions). + ## Getting started -To get started, initialize `AuthService` and install it in your `asab.web.WebContainer`: +To get started, add `asab.web` module to your application and initialize `asab.web.auth.AuthService`: + +```python +import asab +import asab.web +import asab.web.auth + +... -``` python class MyApplication(asab.Application): def __init__(self): super().__init__() - # Initialize web container - self.add_module(asab.web.Module) - self.WebService = self.get_service("asab.WebService") - self.WebContainer = asab.web.WebContainer(self.WebService, "web") + # Initialize web module + asab.web.create_web_server(self) - # Initialize authorization service and install the decorators + # Initialize authorization service self.AuthService = asab.web.auth.AuthService(self) - self.AuthService.install(self.WebContainer) ``` +!!! note + + If your app has more than one web container, you will need to call `AuthService.install(web_container)` to apply + the authorization. + + !!! note You may also need to specify your authorization server's `public_keys_url` (also known as `jwks_uri` in [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8414#section-2)). In case you don't have any authorization server at hand, - you can run the auth module in "mock mode". See the `configuration` section for details. + you can run the auth module in "mock mode". See the [Configuration section](#configuration) for details. -Every handler in `WebContainer` now accepts only requests with a valid authentication. +Every handler in your web server now accepts only requests with a valid authentication. Unauthenticated requests are automatically answered with [HTTP 401: Unauthorized](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401). -Authenticated requests will be inspected for user info, authorized user tenant and resources. -These attributes are passed to the handler method, if the method in question has -the corresponding keyword arguments (`user_info`, `tenant` and `resources`). -In the following example, the method will receive `user_info` and `resources` from the request: - -``` python -async def order_breakfast(self, request, *, tenant, user_info, resources): - user_id = user_info["sub"] - user_name = user_info["preferred_username"] - if "pancakes:eat" in resources: - ... +For every authenticated request, an `asab.web.auth.Authorization` object is created and stored +in `asab.contextvars.Authz` for easy access. +It contains authorization and authentication details, such as `CredentialsId`, `Username` or `Email`, and +access-checking methods `has_resource_access`, `require_superuser_access` and more ([see reference](#asab.web.auth.Authorization)). + +```python +import asab.contextvars +import asab.web.rest + +... + +async def order_breakfast(request): + authz = asab.contextvars.Authz.get() + username = authz.Username + + # This will raise asab.exceptions.AccessDeniedError when the user is not authorized for resource `breakfast:access` + authz.require_resource_access("breakfast:access") + print("{} is ordering breakfast.".format(username)) + + if authz.has_resource_access("breakfast:pancakes"): + print("{} can get pancakes for breakfast!".format(username)) + + if authz.has_superuser_access(): + print("{} can get anything they want!".format(username)) + return asab.web.rest.json_response(request, { - "result": "Good morning {}, your breakfast will be ready in a minute!".format(user_name) + "result": "Good morning {}, your breakfast will be ready in a minute!".format(username) }) ``` See [examples/web-auth.py](https://github.com/TeskaLabs/asab/blob/master/examples/web-auth.py) for a full demo ASAB application with auth module. + ## Configuration The `asab.web.auth` module is configured in the `[auth]` section with the following options: -| Option | Type | Meaning | -|-----------------------|------------------| --- | -| `public_keys_url` | URL | The URL of the authorization server's public keys (also known as `jwks_uri` in [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8414#section-2)) | -| `enabled` | boolean or `"mock"` | Enables or disables authentication and authorization or switches to mock authorization. In mock mode, all incoming requests are authorized with mock user info. There is no communication with the authorization server (so it is not necessary to configure `public_keys_url` in dev mode). -| `mock_user_info_path` | path | Path to JSON file that contains user info claims used in mock mode. The structure of user info should follow the [OpenID Connect userinfo definition](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse) and also contain the `resources` object. +| Option | Type | Meaning | +| --- | --- | --- | +| `public_keys_url` | URL | The URL of the authorization server's public keys (also known as `jwks_uri` in [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8414#section-2)) | +| `enabled` | boolean or `"mock"` | Enables or disables authentication and authorization or switches to mock authorization. In mock mode, all incoming requests are authorized with mock user info. There is no communication with the authorization server (so it is not necessary to configure `public_keys_url` in dev mode). | +| `mock_user_info_path` | path | Path to JSON file that contains user info claims used in mock mode. The structure of user info should follow the [OpenID Connect userinfo definition](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse) and also contain the `resources` object. | Default options: @@ -82,68 +107,26 @@ enabled=yes mock_user_info_path=/conf/mock-userinfo.json ``` -## Multitenancy - -### Strictly multitenant endpoints - -Strictly multitenant endpoints always operate within a tenant, hence they need the `tenant` parameter to be always provided. -Such endpoints must define the `tenant` parameter in their **URL path** and include `tenant` argument in the handler method. -Auth service extracts the tenant from the URL path, validates the tenant existence, -checks if the request is authorized for the tenant, and finally passes the tenant name to the handler method. - -!!! example "Example handler:" - ``` python - class MenuHandler: - def __init__(self, app): - self.MenuService = app.get_service("MenuService") - router = app.WebContainer.WebApp.router - # Add a path with `tenant` parameter - router.add_get("/{tenant}/todays-menu", self.get_todays_menu) - - # Define handler method with `tenant` argument in the signature - async def get_todays_menu(self, request, *, tenant): - menu = await self.MenuService.get_todays_menu(tenant) - return asab.web.rest.json_response(request, data=menu) - ``` - -!!! example "Example request:" - - ``` - GET http://localhost:8080/lazy-raccoon-bistro/todays-menu - ``` - - -### Configurable multitenant endpoints - -Configurable multitenant endpoints usually operate within a tenant, -but they can also operate in tenantless mode if the application is designed for that. - -When you create an endpoint *without* `tenant` parameter in the URL path and *with* `tenant` argument in the -handler method, the Auth service will either expect the `tenant` parameter to be provided in the **URL query**. -If it is not in the query, the tenant variable is set to `None`. +## Multitenancy -!!! example "Example handler:" +Incoming request can specify tenant context using `X-Tenant` HTTP header. +If this header is not empty, `AuthService` extracts the tenant ID and verifies if the request is authorized +to access that tenant. +The request tenant can easily be accessed using `asab.contextvars.Tenant.get()`. - ``` python - class MenuHandler: - def __init__(self, app): - self.MenuService = app.get_service("MenuService") - router = app.WebContainer.WebApp.router - # Add a path without `tenant` parameter - router.add_get("/todays-menu", self.get_todays_menu) +```python +import asab.contextvars +import asab.web.rest - # Define handler method with `tenant` argument in the signature - async def get_todays_menu(self, request, *, tenant): - menu = await self.MenuService.get_todays_menu(tenant) - return asab.web.rest.json_response(request, data=menu) - ``` +... -!!! example "Example requests:" +async def get_todays_menu(request): + tenant = asab.contextvars.Tenant.get() + menu = await get_todays_menu(tenant) + return asab.web.rest.json_response(request, data=menu) +``` - ``` - GET http://localhost:8080/todays-menu?tenant=lazy-raccoon-bistro - ``` ## Mock mode @@ -160,11 +143,15 @@ mock_user_info_path=${THIS_DIR}/mock_userinfo.json When dev mode is enabled, you don't have to provide `[public_keys_url]` since this option is ignored. + ## Reference ::: asab.web.auth.AuthService +::: asab.web.auth.Authorization + + ::: asab.web.auth.require ::: asab.web.auth.noauth diff --git a/examples/web-auth.py b/examples/web-auth.py index cb06fd10f..bdc3f9fc5 100644 --- a/examples/web-auth.py +++ b/examples/web-auth.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import typing +import secrets import asab.web.rest import asab.web.auth import asab.contextvars @@ -29,7 +30,11 @@ } -class MyApplication(asab.Application): +class NotesApplication(asab.Application): + """ + Web application with a simple CRUD REST API for notes management. + Demonstrates the usage of the authorization module (asab.web.auth). + """ def __init__(self): super().__init__() @@ -47,76 +52,151 @@ def __init__(self): # Initialize authorization self.AuthService = asab.web.auth.AuthService(self) - self.AuthService.install(self.WebContainer) # Add routes - self.WebContainer.WebApp.router.add_get("/noauth", self.noauth) - self.WebContainer.WebApp.router.add_get("/authn", self.authn) - self.WebContainer.WebApp.router.add_get("/authz", self.authz) + self.WebContainer.WebApp.router.add_get("/", self.info) + self.WebContainer.WebApp.router.add_get("/note", self.list_notes) + self.WebContainer.WebApp.router.add_post("/note", self.create_note) + self.WebContainer.WebApp.router.add_get("/note/{note_id}", self.read_note) + self.WebContainer.WebApp.router.add_put("/note/{note_id}", self.edit_note) + self.WebContainer.WebApp.router.add_delete("/note/{note_id}", self.delete_note) + + # Notes storage + self.Notes: typing.Dict[str, typing.Dict[str, typing.Dict[str, str]]] = { + # Add a few notes so that the storage is not empty + "default": { # Tenant ID + "12345678": { # Note ID + "_id": "12345678", + "created_by": "that-one-developer:)", + "content": "This is an example note in tenant 'default'!", + } + }, + "brothers-workspace": { + "abcdefgh": { + "_id": "abcdefgh", + "created_by": "that-one-developer:)", + "content": "This is another example note, this time in tenant 'brothers-workspace'!", + } + }, + } @asab.web.auth.noauth - async def noauth(self, request): + async def info(self, request): """ - NO AUTH - - no authentication or authorization required - - `tenant`, `user_info`, `resources` params not allowed + Show application info. + + No authentication or authorization required, but also no user details are available. """ + + # Tenant context is not set for endpoint with @asab.web.auth.noauth decorator. + # `asab.contextvars.Tenant.get()` will throw LookupError + # Same with `asab.contextvars.Authz.get()` + data = { - "tenant": "NOT AVAILABLE", - "resources": "NOT AVAILABLE", - "user_info": "NOT AVAILABLE", + "message": "This app stores notes. Call GET /note to see stored notes.", } return asab.web.rest.json_response(request, data) - async def authn( - self, - request, - *, - user_info: typing.Optional[dict], - resources: typing.Optional[frozenset], - ): + async def list_notes(self, request): """ - AUTHENTICATION REQUIRED - - request must be authenticated - - if there is a tenant ID in the X-Tenant header, the request must be authorized to access that tenant - - returns 401 if authentication not successful + Show notes stored in the current tenant. + + Authentication required. """ tenant = asab.contextvars.Tenant.get() + authz = asab.contextvars.Authz.get() + + notes = self.Notes.get(tenant, {}) data = { - "tenant": tenant, - "resources": list(resources) if resources else None, - "user_info": user_info, + "count": len(notes), # Anybody can see how many notes are there } + if authz.has_resource_access("note:read"): + # Seeing the actual notes requires authorized access to "note:read" + data["data"] = notes + return asab.web.rest.json_response(request, data) - @asab.web.auth.require("web-auth:access") - async def authz( - self, - request, - *, - user_info: typing.Optional[dict], - resources: typing.Optional[frozenset] - ): + @asab.web.auth.require("note:read") + async def read_note(self, request): + """ + Find note by ID in the current tenant and return it. + + Authentication and authorization of "note:read" required. + """ + tenant = asab.contextvars.Tenant.get() + + note_id = request.match_info["note_id"] + if tenant in self.Notes and note_id in self.Notes[tenant]: + return asab.web.rest.json_response(request, self.Notes[tenant][note_id]) + else: + return asab.web.rest.json_response(request, {"result": "NOT-FOUND"}, status=404) + + + @asab.web.rest.json_schema_handler( + {"type": "string"} + ) + @asab.web.auth.require("note:edit") + async def create_note(self, request, *, json_data): """ - AUTHORIZATION REQUIRED - - this endpoint is a protected resource - - request must be authenticated and authorized to access this resource - - if there is a tenant ID in the X-Tenant header, the request must be authorized to access the resource within that tenant - - returns 401 if authentication not successful - - returns 403 if authorization not successful + Create a note in the current tenant. + + Authentication and authorization of "note:edit" required. """ tenant = asab.contextvars.Tenant.get() - data = { - "tenant": tenant, - "resources": list(resources) if resources else None, - "user_info": user_info, + authz = asab.contextvars.Authz.get() + + if not tenant in self.Notes: + self.Notes[tenant] = {} + note_id = secrets.token_urlsafe(8) + self.Notes[tenant][note_id] = { + "_id": note_id, + "created_by": authz.CredentialsId, + "content": json_data, } - return asab.web.rest.json_response(request, data) + return asab.web.rest.json_response(request, {"_id": note_id}, status=201) + + + @asab.web.rest.json_schema_handler( + {"type": "string"} + ) + @asab.web.auth.require("note:edit") + async def edit_note(self, request, *, json_data): + """ + Update an existing note in the current tenant. + + Authentication and authorization of "note:edit" required. + """ + tenant = asab.contextvars.Tenant.get() + authz = asab.contextvars.Authz.get() + + if tenant in self.Notes and note_id in self.Notes[tenant]: + self.Notes[tenant][note_id]["content"] = json_data + return asab.web.rest.json_response(request, {"result": "OK"}) + else: + return asab.web.rest.json_response(request, {"result": "NOT-FOUND"}, status=404) + + + @asab.web.auth.require("note:delete") + async def delete_note(self, request): + """ + Find note by ID in the current tenant and delete it. + + Authentication and authorization of "note:delete" required. + """ + tenant = asab.contextvars.Tenant.get() + authz = asab.contextvars.Authz.get() + + note_id = request.match_info["note_id"] + if tenant in self.Notes and note_id in self.Notes[tenant]: + del self.Notes[tenant][note_id] + return asab.web.rest.json_response(request, {"result": "OK"}) + else: + return asab.web.rest.json_response(request, {"result": "NOT-FOUND"}, status=404) if __name__ == "__main__": - app = MyApplication() + app = NotesApplication() app.run()