From 1876c77f47a3d4707af0e9d564c4fcf6e6ff0124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Hru=C5=A1ka?= Date: Fri, 16 Aug 2024 17:32:21 +0200 Subject: [PATCH] cookie request refactoring wip --- seacatauth/cookie/handler.py | 217 ++++----------------------- seacatauth/cookie/service.py | 279 ++++++++++++++++++++++++++++++++++- 2 files changed, 304 insertions(+), 192 deletions(-) diff --git a/seacatauth/cookie/handler.py b/seacatauth/cookie/handler.py index 05c22d08..da1bdd8e 100644 --- a/seacatauth/cookie/handler.py +++ b/seacatauth/cookie/handler.py @@ -8,6 +8,7 @@ from .. import exceptions, AuditLogger from .. import generic +from ..contextvars import AccessIps from ..openidconnect.utils import TokenRequestErrorResponseCode # @@ -93,13 +94,13 @@ def __init__(self, app, cookie_svc, session_svc, credentials_svc): web_app = app.WebContainer.WebApp web_app.router.add_post("/nginx/introspect/cookie", self.nginx) web_app.router.add_post("/nginx/introspect/cookie/anonymous", self.nginx_anonymous) - web_app.router.add_get("/cookie/entry", self.bouncer_get) - web_app.router.add_post("/cookie/entry", self.bouncer_post) + web_app.router.add_get("/cookie/entry", self.cookie_get) + web_app.router.add_post("/cookie/entry", self.cookie_post) # Public endpoints web_app_public = app.PublicWebContainer.WebApp - web_app_public.router.add_get("/cookie/entry", self.bouncer_get) - web_app_public.router.add_post("/cookie/entry", self.bouncer_post) + web_app_public.router.add_get("/cookie/entry", self.cookie_get) + web_app_public.router.add_post("/cookie/entry", self.cookie_post) # TODO: Insecure, back-compat only - remove after 2024-03-31 if asab.Config.getboolean("seacatauth:introspection", "_enable_insecure_legacy_endpoints", fallback=False): @@ -288,7 +289,7 @@ async def nginx_anonymous(self, request): return response - async def bouncer_get(self, request): + async def cookie_get(self, request): """ Exchange authorization code for cookie and redirect to specified redirect URI. @@ -320,10 +321,10 @@ async def bouncer_get(self, request): type: string """ params = request.query - return await self._bouncer(request, params) + return await self._cookie(request, params) - async def bouncer_post(self, request): + async def cookie_post(self, request): """ Exchange authorization code for cookie and redirect to specified redirect URI. @@ -355,132 +356,29 @@ async def bouncer_post(self, request): - redirect_uri """ params = await request.post() - return await self._bouncer(request, params) + return await self._cookie(request, params) - async def _bouncer(self, request, parameters): + async def _cookie(self, request, parameters): """ Exchange authorization code for cookie and redirect to specified redirect URI. """ - client_svc = self.App.get_service("seacatauth.ClientService") - from_ip = generic.get_request_access_ips(request) - - client_id = parameters.get("client_id") - if client_id is None: - AuditLogger.log( - asab.LOG_NOTICE, - "Cookie request denied: No 'client_id' in request query", - struct_data={"from_ip": from_ip} - ) - return asab.web.rest.json_response( - request, {"error": TokenRequestErrorResponseCode.InvalidRequest}, status=400) - try: - client = await client_svc.get(client_id) - except KeyError: - AuditLogger.log( - asab.LOG_NOTICE, - "Cookie request denied: Client not found", - struct_data={"from_ip": from_ip, "client_id": client_id} - ) - return asab.web.rest.json_response( - request, {"error": TokenRequestErrorResponseCode.InvalidClient}, status=400) - - grant_type = parameters.get("grant_type") - if grant_type != "authorization_code": - AuditLogger.log( - asab.LOG_NOTICE, - "Cookie request denied: Unsupported grant type", - struct_data={ - "client_id": client_id, - "from_ip": from_ip, - "grant_type": grant_type, - } - ) - return asab.web.rest.json_response( - request, {"error": TokenRequestErrorResponseCode.UnsupportedGrantType}, status=400) - - # Use the code to get session ID - authorization_code = parameters.get("code") - if not authorization_code: - AuditLogger.log( - asab.LOG_NOTICE, - "Cookie request denied: No 'code' in request query", - struct_data={ - "client_id": client_id, - "from_ip": from_ip, - } - ) - return asab.web.rest.json_response( - request, {"error": TokenRequestErrorResponseCode.InvalidRequest}, status=400) - try: - session = await self.CookieService.get_session_by_authorization_code(authorization_code) - except KeyError: - AuditLogger.log( - asab.LOG_NOTICE, - "Cookie request denied: Invalid or expired authorization code", - struct_data={ - "client_id": client_id, - "from_ip": from_ip, - } - ) - return asab.web.rest.json_response( - request, {"error": TokenRequestErrorResponseCode.InvalidGrant}, status=400) - - # Determine the destination URI - if "redirect_uri" in parameters: - # Use the redirect URI from request query - # TODO: Optionally validate the URI against client["redirect_uris"] - # and check if it is the same as in the authorization request - redirect_uri = parameters["redirect_uri"] - else: - # Fallback to client URI or Auth UI - redirect_uri = client.get("client_uri") or self.CookieService.AuthWebUiBaseUrl.rstrip("/") - - # Set track ID if not set yet - if session.TrackId is None: - session = await self.SessionService.inherit_track_id_from_root(session) - if session.TrackId is None: - # Obtain the old session by request cookie or access token - try: - old_session = await self.CookieService.get_session_by_request_cookie( - request, session.OAuth2.ClientId) - except exceptions.SessionNotFoundError: - old_session = None - except exceptions.NoCookieError: - old_session = None - - token_value = generic.get_bearer_token_value(request) - if old_session is None and token_value is not None: - try: - old_session = await self.CookieService.OpenIdConnectService.get_session_by_access_token(token_value) - except exceptions.SessionNotFoundError: - # Invalid access token should result in error - AuditLogger.log( - asab.LOG_NOTICE, - "Cookie request denied: Track ID transfer failed because of invalid Authorization header", - struct_data={ - "cid": session.Credentials.Id, - "sid": session.Id, - "client_id": session.OAuth2.ClientId, - "from_ip": from_ip, - "redirect_uri": redirect_uri - } - ) - return aiohttp.web.HTTPBadRequest() - try: - session = await self.SessionService.inherit_or_generate_new_track_id(session, old_session) - except ValueError as e: - # Return 400 to prevent disclosure while keeping the stacktrace - L.error("Failed to produce session track ID") - raise aiohttp.web.HTTPBadRequest() from e - - session = await self.CookieService.extend_session_expiration(session, client) + for param in {"client_id", "grant_type", "code"}: + if param not in parameters: + AuditLogger.log( + asab.LOG_NOTICE, + "Cookie request denied: No '{}' in request query".format(param), + struct_data={"access_ips": AccessIps.get()} + ) + return asab.web.rest.json_response( + request, {"error": TokenRequestErrorResponseCode.InvalidRequest}, status=400) - # Construct the response - if client.get("cookie_domain") not in (None, ""): - cookie_domain = client["cookie_domain"] - else: - cookie_domain = self.CookieService.RootCookieDomain + cookie, redirect_uri, client_headers = await self.CookieService.process_cookie_request( + request, + client_id=parameters["client_id"], + grant_type=parameters["grant_type"], + code=parameters["code"], + ) response = aiohttp.web.HTTPFound( redirect_uri, @@ -492,39 +390,17 @@ async def _bouncer(self, request, parameters): text="\n\n...\n\n" ) - # TODO: Verify that the request came from the correct domain + # Add headers from webhook + response.headers.update(client_headers) + # Add Seacat Auth cookie self.CookieService.set_session_cookie( response=response, - cookie_value=session.Cookie.Id, - client_id=client_id, - cookie_domain=cookie_domain + client_id=parameters["client_id"], + cookie_value=cookie["value"], + cookie_domain=cookie.get("domain"), ) - # Trigger webhook and set custom client response headers - try: - data = await self._fetch_webhook_data(client, session) - if data is not None: - response.headers.update(data.get("response_headers", {})) - except exceptions.ClientResponseError as e: - L.log(asab.LOG_NOTICE, "Webhook responded with error", struct_data={ - "status": e.Status, "text": e.Data}) - AuditLogger.log(asab.LOG_NOTICE, "Cookie request denied: Webhook error", struct_data={ - "cid": session.Credentials.Id, - "sid": session.Id, - "client_id": session.OAuth2.ClientId, - "from_ip": from_ip, - "redirect_uri": redirect_uri}) - return asab.web.rest.json_response( - request, {"error": TokenRequestErrorResponseCode.InvalidRequest}, status=400) - - AuditLogger.log(asab.LOG_NOTICE, "Cookie request granted", struct_data={ - "cid": session.Credentials.Id, - "sid": session.Id, - "client_id": session.OAuth2.ClientId, - "from_ip": from_ip, - "redirect_uri": redirect_uri}) - return response @@ -542,34 +418,3 @@ async def _authenticate_request(self, request, client_id=None): return None return session - - - async def _fetch_webhook_data(self, client, session): - """ - Make a webhook request and return the response body. - The response should match the following schema: - ```json - { - "type": "object", - "properties": { - "response_headers": { - "type": "object", - "description": "HTTP headers and their values that will be added to the response." - } - } - } - ``` - """ - cookie_webhook_uri = client.get("cookie_webhook_uri") - if cookie_webhook_uri is None: - return None - async with aiohttp.ClientSession() as http_session: - # TODO: Better serialization - userinfo = await self.CookieService.OpenIdConnectService.build_userinfo(session) - data = asab.web.rest.json.JSONDumper(pretty=False)(userinfo) - async with http_session.put(cookie_webhook_uri, data=data, headers={ - "Content-Type": "application/json"}) as resp: - if resp.status != 200: - text = await resp.text() - raise exceptions.ClientResponseError(resp.status, text) - return await resp.json() diff --git a/seacatauth/cookie/service.py b/seacatauth/cookie/service.py index 26dedc83..f3ee0a78 100644 --- a/seacatauth/cookie/service.py +++ b/seacatauth/cookie/service.py @@ -5,13 +5,17 @@ import logging import typing +import aiohttp import asab +import asab.web import asab.storage import asab.exceptions -from .. import exceptions +from ..contextvars import AccessIps +from .. import exceptions, generic from ..session.adapter import SessionAdapter, CookieData from ..session.builders import cookie_session_builder +from ..authz import build_credentials_authz from .. import AuditLogger # @@ -21,6 +25,12 @@ # +class CookieToken: + TokenType = "ct" + ByteLength = asab.Config.getint("cookie", "token_length") + Expiration = asab.Config.getseconds("cookie", "expiration") + + class CookieService(asab.Service): """ Manage cookie sessions @@ -33,6 +43,7 @@ def __init__(self, app, service_name="seacatauth.CookieService"): self.CredentialsService = app.get_service("seacatauth.CredentialsService") self.RoleService = app.get_service("seacatauth.RoleService") self.TenantService = app.get_service("seacatauth.TenantService") + self.TokenService = app.get_service("seacatauth.SessionTokenService") self.AuthenticationService = None self.OpenIdConnectService = None @@ -128,10 +139,6 @@ async def get_session_by_session_cookie_value(self, cookie_value: str): return session - async def get_session_by_authorization_code(self, code): - return await self.OpenIdConnectService.get_session_by_authorization_code(code) - - async def create_cookie_client_session( self, root_session, client_id, scope, nonce=None, @@ -158,7 +165,6 @@ async def create_cookie_client_session( nonce=nonce, redirect_uri=redirect_uri, ) - session_builders.append(cookie_session_builder()) session = await self.SessionService.create_session( session_type="cookie", @@ -239,3 +245,264 @@ def delete_session_cookie(self, response, client_id: typing.Optional[str] = None """ cookie_name = self.get_cookie_name(client_id) response.del_cookie(cookie_name) + + + async def process_cookie_request( + self, + request, + client_id: str, + grant_type: str, + code: str, + redirect_uri: typing.Optional[str] + ) -> typing.Tuple[ + typing.Mapping[str, typing.Any], + str, + typing.Mapping[str, typing.Any], + ]: + client_svc = self.App.get_service("seacatauth.ClientService") + + session = await self.CookieService.get_session_by_authorization_code( + request, + client_id=client_id, + grant_type=grant_type, + code=code, + ) + + if session.is_algorithmic(): + cookie_value = self.SessionService.Algorithmic.serialize(session) + cookie_valid_until = self.SessionService.AnonymousExpiration + else: + cookie_value, cookie_valid_until = await self._create_cookie_token(session) + await self.refresh_session( + session, + valid_until=cookie_valid_until, + delete_after=cookie_valid_until, + ) + + client = await client_svc.get(client_id) + + if client.get("cookie_domain") not in (None, ""): + domain = client["cookie_domain"] + else: + domain = self.RootCookieDomain + + cookie = { + "value": cookie_value, + "max_age": None, + "domain": domain + } + + # Determine the destination URI + if not redirect_uri: + # Fallback to client URI or Auth UI + redirect_uri = client.get("client_uri") or self.AuthWebUiBaseUrl.rstrip("/") + # TODO: Optionally validate the URI against client["redirect_uris"] + # and check if it is the same as in the authorization request + + # Trigger webhook and set custom client response headers + try: + data = await self._fetch_webhook_data(client, session) + headers = data.get("response_headers", {}) + except exceptions.ClientResponseError as e: + AuditLogger.error("Cookie request denied: Webhook error", struct_data={ + "cid": session.Credentials.Id, + "sid": session.Id, + "client_id": session.OAuth2.ClientId, + "from_ip": AccessIps.get(), + "redirect_uri": redirect_uri + }) + raise InvalidRequest() from e + + AuditLogger.log(asab.LOG_NOTICE, "Cookie request granted", struct_data={ + "cid": session.Credentials.Id, + "sid": session.Id, + "client_id": session.OAuth2.ClientId, + "from_ip": AccessIps.get(), + "redirect_uri": redirect_uri + }) + + return cookie, redirect_uri, headers + + + async def get_session_by_authorization_code(self, client_id: str, grant_type: str, code: str): + if grant_type != "authorization_code": + AuditLogger.log( + asab.LOG_NOTICE, + "Cookie request denied: Unsupported grant type.", + struct_data={ + "client_id": client_id, + "access_ips": AccessIps.get(), + "grant_type": grant_type, + } + ) + raise UnsupportedGrantType(grant_type) + + try: + session = await self.OpenIdConnectService.get_session_by_authorization_code(code) + except exceptions.SessionNotFoundError: + AuditLogger.log( + asab.LOG_NOTICE, + "Cookie request denied: Invalid or expired authorization code", + struct_data={ + "client_id": client_id, + "access_ips": AccessIps.get(), + } + ) + raise InvalidGrant() + + if client_id != session.OAuth2.ClientId: + AuditLogger.log( + asab.LOG_NOTICE, + "Cookie request denied: Invalid client.", + struct_data={ + "client_id": client_id, + "access_ips": AccessIps.get(), + } + ) + raise InvalidClient(client_id) + + return session + + + async def create_cookie(self, request, session: SessionAdapter) -> typing.Tuple[str, float]: + # Establish and propagate track ID + try: + session = await self._set_track_id(request, session) + except ValueError as e: + AuditLogger.error( + "Token request denied: Failed to produce session track ID", + struct_data={ + "from_ip": AccessIps.get(), + "cid": session.Credentials.Id, + "client_id": session.OAuth2.ClientId, + } + ) + raise e + + + async def _set_track_id(self, request, session: SessionAdapter) -> typing.Tuple[str, float]: + # Set track ID if not set yet + if session.TrackId is None: + session = await self.SessionService.inherit_track_id_from_root(session) + if session.TrackId is None: + # Obtain the old session by request cookie or access token + try: + old_session = await self.get_session_by_request_cookie( + request, session.OAuth2.ClientId) + except exceptions.SessionNotFoundError: + old_session = None + except exceptions.NoCookieError: + old_session = None + + token_value = generic.get_bearer_token_value(request) + if old_session is None and token_value is not None: + try: + old_session = await self.OpenIdConnectService.get_session_by_access_token(token_value) + except exceptions.SessionNotFoundError: + old_session = None + try: + session = await self.SessionService.inherit_or_generate_new_track_id(session, old_session) + except ValueError as e: + L.error("Failed to produce session track ID") + raise e + return session + + + async def _create_cookie_token(self, session: SessionAdapter) -> typing.Tuple[str, float]: + """ + Create cookie token + + @param session: Target session + @return: Base64-encoded token and its expiration + """ + client_svc = self.App.get_service("seacatauth.ClientService") + client = await client_svc.get(session.OAuth2.ClientId) + expires_in = client.get("session_expiration") or CookieToken.Expiration + raw_value, valid_until = await self.TokenService.create( + token_length=CookieToken.ByteLength, + token_type=CookieToken.TokenType, + session_id=session.SessionId, + expiration=expires_in, + is_session_algorithmic=session.is_algorithmic(), + ) + return base64.urlsafe_b64encode(raw_value).decode("ascii"), valid_until + + + async def refresh_session( + self, + session: SessionAdapter, + valid_until: typing.Optional[datetime.datetime] = None, + delete_after: typing.Optional[datetime.datetime] = None, + ): + """ + Update/rebuild the session according to its authorization parameters + """ + # Get parent session + root_session = await self.SessionService.get(session.Session.ParentSessionId) + + # Exclude critical resource grants from impersonated sessions + if root_session.Authentication.ImpersonatorSessionId is not None: + exclude_resources = {"authz:superuser", "authz:impersonate"} + else: + exclude_resources = set() + + # Authorize tenant + authz = await build_credentials_authz( + self.TenantService, self.RoleService, root_session.Credentials.Id, + tenants=None, + exclude_resources=exclude_resources + ) + authorized_tenant = await self.get_accessible_tenant_from_scope( + session.OAuth2.Scope, root_session.Credentials.Id, + has_access_to_all_tenants=self.RBACService.can_access_all_tenants(authz) + ) + + session_builders = await self.SessionService.build_client_session( + root_session, + client_id=session.OAuth2.ClientId, + scope=session.OAuth2.Scope, + tenants=[authorized_tenant] if authorized_tenant else None, + nonce=session.OAuth2.Nonce, + redirect_uri=session.OAuth2.RedirectUri, + ) + + if valid_until: + session_builders.append(((SessionAdapter.FN.Session.Expiration, valid_until),)) + + if delete_after: + session_builders.append(((SessionAdapter.FN.Session.DeleteAfter, delete_after),)) + + session = await self.SessionService.update_session(session.SessionId, session_builders) + + return session + + + async def _fetch_webhook_data(self, client, session): + """ + Make a webhook request and return the response body. + The response should match the following schema: + ```json + { + "type": "object", + "properties": { + "response_headers": { + "type": "object", + "description": "HTTP headers and their values that will be added to the response." + } + } + } + ``` + """ + cookie_webhook_uri = client.get("cookie_webhook_uri") + if cookie_webhook_uri is None: + return None + async with aiohttp.ClientSession() as http_session: + # TODO: Better serialization + userinfo = await self.OpenIdConnectService.build_userinfo(session) + data = asab.web.rest.json.JSONDumper(pretty=False)(userinfo) + async with http_session.put(cookie_webhook_uri, data=data, headers={ + "Content-Type": "application/json"}) as resp: + if resp.status != 200: + text = await resp.text() + raise exceptions.ClientResponseError(resp.status, text) + return await resp.json()