From d9e3b3f6913fffef5b378bd35b0d7da7451bda00 Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Mon, 21 Oct 2024 14:13:54 +1100 Subject: [PATCH] Add support for generating proxy tokens for lookup service client. --- .../lookup-service/upstream/clusterroles.yaml | 1 + .../upstream/crd-clientconfig.yaml | 14 ++ lookup-service/service/caches/clientconfig.py | 18 ++- .../service/handlers/clientconfigs.py | 22 ++- lookup-service/service/main.py | 8 +- lookup-service/service/routes/authnz.py | 140 +++++++++++++++--- lookup-service/service/routes/workshops.py | 16 +- lookup-service/testing/clientconfigs.yaml | 44 ++++++ 8 files changed, 231 insertions(+), 32 deletions(-) diff --git a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/clusterroles.yaml b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/clusterroles.yaml index 1b7632b6..8ab5f164 100644 --- a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/clusterroles.yaml +++ b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/clusterroles.yaml @@ -56,6 +56,7 @@ rules: - apiGroups: - lookup.educates.dev resources: + - corsconfigs/finalizers - clusterconfigs/finalizers - clientconfigs/finalizers - tenantconfigs/finalizers diff --git a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml index ed63f69f..38ce6977 100644 --- a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml +++ b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml @@ -30,9 +30,23 @@ spec: required: - password properties: + user: + type: string password: type: string minLength: 8 + proxy: + type: object + required: + - secret + properties: + issuer: + type: string + minLength: 1 + secret: + type: string + minLength: 8 + #! Deprecated, use "spec.client.user". user: type: string roles: diff --git a/lookup-service/service/caches/clientconfig.py b/lookup-service/service/caches/clientconfig.py index 64eb6ca1..94048034 100644 --- a/lookup-service/service/caches/clientconfig.py +++ b/lookup-service/service/caches/clientconfig.py @@ -1,5 +1,6 @@ """Configuration for clients of the service.""" +import datetime import fnmatch from dataclasses import dataclass from typing import List, Set @@ -11,9 +12,11 @@ class ClientConfig: name: str uid: str - issue: int + start: int password: str user: str + issuer: str + proxy: str tenants: List[str] roles: List[str] @@ -21,12 +24,14 @@ class ClientConfig: def identity(self) -> str: """Return the identity of the client.""" - return f"client@educates:{self.uid}#{self.issue}" + return self.uid def revoke_tokens(self) -> None: - """Revoke all tokens issued to the client.""" + """Revoke all tokens issued to the client by updating start time.""" - self.issue += 1 + time_now = datetime.datetime.now(datetime.timezone.utc) + + self.start = int(time_now.timestamp()) def check_password(self, password: str) -> bool: """Checks the password provided against the client's password.""" @@ -38,6 +43,11 @@ def validate_identity(self, identity: str) -> bool: return self.identity == identity + def validate_time_window(self, issued_at: int) -> bool: + """Validate issue time for token falls within allowed time window.""" + + return issued_at >= self.start + def has_required_role(self, *roles: str) -> Set: """Check if the client has any of the roles provided. We return back a set containing the roles that matched.""" diff --git a/lookup-service/service/handlers/clientconfigs.py b/lookup-service/service/handlers/clientconfigs.py index 1feec914..f73a5a10 100644 --- a/lookup-service/service/handlers/clientconfigs.py +++ b/lookup-service/service/handlers/clientconfigs.py @@ -1,5 +1,6 @@ """Operator handlers for client configuration resources.""" +import datetime import logging from typing import Any, Dict @@ -25,9 +26,22 @@ def clientconfigs_update( client_name = name client_uid = xgetattr(meta, "uid") + client_password = xgetattr(spec, "client.password") - client_user = xgetattr(spec, "user") + + client_user = xgetattr(spec, "client.user") + + # The "user" field was deprecated in favor of "client.user". Accept the + # "user" field if "client.user" is not provided for backwards compatibility. + + if not client_user: + client_user = xgetattr(spec, "user") + + client_issuer = xgetattr(spec, "client.proxy.issuer") + client_proxy = xgetattr(spec, "client.proxy.secret", "") + client_tenants = xgetattr(spec, "tenants", []) + client_roles = xgetattr(spec, "roles", []) logger.info( @@ -39,13 +53,17 @@ def clientconfigs_update( client_database = memo.client_database + time_now = datetime.datetime.now(datetime.timezone.utc) + client_database.update_client( ClientConfig( name=client_name, uid=client_uid, - issue=1, + start=int(time_now.timestamp()), password=client_password, user=client_user, + issuer=client_issuer, + proxy=client_proxy, tenants=client_tenants, roles=client_roles, ) diff --git a/lookup-service/service/main.py b/lookup-service/service/main.py index 936a188f..a3db173f 100644 --- a/lookup-service/service/main.py +++ b/lookup-service/service/main.py @@ -12,7 +12,13 @@ import kopf import pykube -from .caches.databases import cors_database, client_database, cluster_database, tenant_database +from .caches.databases import ( + cors_database, + client_database, + cluster_database, + tenant_database, +) + from .handlers import corsconfigs as _ # pylint: disable=unused-import from .handlers import clientconfigs as _ # pylint: disable=unused-import from .handlers import clusterconfigs as _ # pylint: disable=unused-import diff --git a/lookup-service/service/routes/authnz.py b/lookup-service/service/routes/authnz.py index 9a85ff87..d89e4125 100644 --- a/lookup-service/service/routes/authnz.py +++ b/lookup-service/service/routes/authnz.py @@ -6,7 +6,7 @@ import datetime import fnmatch import logging -from typing import Callable +from typing import List, Callable import jwt from aiohttp import web @@ -16,6 +16,8 @@ TOKEN_EXPIRATION = 72 # Expiration in hours. +logger = logging.getLogger("educates") + def origin_is_allowed(request_origin, allowed_origins): """We need to check whether the request origin matches any of the allowed @@ -57,7 +59,9 @@ async def cors_allow_origin( response.headers["Access-Control-Allow-Origin"] = request_origin response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" - response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type" + response.headers["Access-Control-Allow-Headers"] = ( + "Authorization, Content-Type" + ) return response @@ -72,20 +76,38 @@ async def cors_allow_origin( return response -def generate_login_response(client: ClientConfig) -> dict: +def generate_login_response( + request: web.Request, client: ClientConfig, expires_at: int = None, user: str = None +) -> dict: """Generate a JWT token for the client. The token will be set to expire and - will need to be renewed. The token will contain the username and the unique - identifier for the client.""" - - expires_at = int( - ( - datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(hours=TOKEN_EXPIRATION) - ).timestamp() - ) + will need to be renewed. The token will contain the username and unique + identifier for the client as well as optional user which is being + impersonated.""" + + time_now = datetime.datetime.now(datetime.timezone.utc) + + issued_at = int(time_now.timestamp()) + + if expires_at is None: + expires_at = int( + (time_now + datetime.timedelta(hours=TOKEN_EXPIRATION)).timestamp() + ) + + issuer = str(request.url.with_path("/").with_query(None)) + + jwt_data = { + "iss": issuer, + "sub": client.name, + "jti": client.identity, + "iat": issued_at, + "exp": expires_at, + } + + if user: + jwt_data["act"] = {"sub": user} jwt_token = jwt.encode( - {"sub": client.name, "jti": client.identity, "exp": expires_at}, + jwt_data, jwt_token_secret(), algorithm="HS256", ) @@ -97,11 +119,14 @@ def generate_login_response(client: ClientConfig) -> dict: } -def decode_client_token(token: str) -> dict: +def decode_client_token(issuer: str, token: str, secret: str = None) -> dict: """Decode the client token and return the decoded token. If the token is invalid, an exception will be raised.""" - return jwt.decode(token, jwt_token_secret(), algorithms=["HS256"]) + if not secret: + secret = jwt_token_secret() + + return jwt.decode(token, secret, algorithms=["HS256"], issuer=issuer) @web.middleware @@ -133,7 +158,8 @@ async def jwt_token_middleware( try: token = parts[1] - decoded_token = decode_client_token(token) + issuer = str(request.url.with_path("/").with_query(None)) + decoded_token = decode_client_token(issuer, token) except jwt.ExpiredSignatureError: return web.Response(text="JWT token has expired", status=401) except jwt.InvalidTokenError: @@ -175,6 +201,9 @@ async def wrapper(request: web.Request) -> web.Response: if not client.validate_identity(decoded_token["jti"]): return web.Response(text="Client identity does not match", status=401) + if not client.validate_time_window(decoded_token.get("iat", 0)): + return web.Response(text="Token issued outside time window", status=401) + request["remote_client"] = client # Continue processing the request. @@ -230,23 +259,82 @@ async def api_auth_login(request: web.Request) -> web.Response: if password is None: return web.Response(text="No password provided", status=400) - # Check if the password is correct for the username. + # Check if the password is correct for the username. We need to work out + # whether the client is gated by normal password, or whether expect to + # be supplied with a proxy token which delgates authority to an alternate + # user. service_state = request.app["service_state"] client_database = service_state.client_database - client = client_database.authenticate_client(username, password) + client = client_database.get_client(username) + + expires_at = None + client_user = None if not client: return web.Response(text="Invalid username/password", status=401) - # Generate a JWT token for the user and return it. The response is - # bundle with the token type and expiration time so they can be used - # by the client without needing to parse the actual JWT token. + if client.password: + if client.check_password(password): + # Generate a JWT token for the user and return it. The response is + # bundle with the token type and expiration time so they can be used + # by the client without needing to parse the actual JWT token. + + token = generate_login_response(request, client, expires_at) + + return web.json_response(token) + + if client.proxy: + # Decode the proxy token. The token will use the "sub" field to store + # the name of the user (email) that the token is for. The "exp" field + # may store the expiration time for the token after which it will no + # longer be accepted for login. The "nbf" field may store the time + # before which the token is not valid. + + try: + decoded_token = decode_client_token(client.issuer, password, client.proxy) + + # Verify that a user has been provided and copy the expiration + # time from the proxy token if it is present so it can be used in + # the session token. + + client_user = decoded_token.get("sub") + + if not client_user: + return web.Response(text="Proxy token missing user", status=401) + + expires_at = decoded_token.get("exp") + + except jwt.exceptions.MissingRequiredClaimError: + return web.Response(text="Missing required claim in proxy token", status=401) - token = generate_login_response(client) + except jwt.InvalidIssuerError: + return web.Response(text="Invalid proxy token issuer", status=401) - return web.json_response(token) + except jwt.InvalidIssuedAtError: + return web.Response(text="Proxy token issued in the future", status=401) + + except jwt.ImmatureSignatureError: + return web.Response(text="Proxy token not yet active", status=401) + + except jwt.ExpiredSignatureError: + return web.Response(text="Proxy has expired", status=401) + + except jwt.InvalidTokenError: + return web.Response(text="Invalid proxy token", status=401) + + # Generate a JWT token for the user and return it. The response is + # bundle with the token type and expiration time so they can be used + # by the client without needing to parse the actual JWT token. + + token = generate_login_response(request, client, expires_at, client_user) + + return web.json_response(token) + + # Return that credentials are invalid. + + return web.Response(text="Invalid username/password", status=401) async def api_auth_logout(request: web.Request) -> web.Response: @@ -275,6 +363,12 @@ async def api_auth_logout(request: web.Request) -> web.Response: if not client.validate_identity(decoded_token["jti"]): return web.Response(text="Client identity does not match", status=401) + if not client.validate_time_window(decoded_token.get("iat", 0)): + return web.Response(text="Token no longer valid", status=401) + + if decoded_token.get("act"): + return web.Response(text="Logout not supported for proxy tokens", status=400) + # Revoke the tokens issued to the client. client.revoke_tokens() diff --git a/lookup-service/service/routes/workshops.py b/lookup-service/service/routes/workshops.py index 3df94296..dfebfedb 100644 --- a/lookup-service/service/routes/workshops.py +++ b/lookup-service/service/routes/workshops.py @@ -88,22 +88,34 @@ async def api_post_v1_workshops(request: web.Request) -> web.Response: service_state = request.app["service_state"] + decoded_token = request["jwt_token"] client = request["remote_client"] tenant_name = data.get("tenantName") + # We will have an "act" field in the decoded token if originally logged in + # using a proxy token. In this case, we will use the user ID from the token. + # If there is no "act" field, then we will use the user ID from the client + # configuration. If there is no user ID in the client configuration, then we + # will use the user ID from the request data. If there is no user ID in the + # request data, then we will use an empty string. + + user_id = decoded_token.get("act", {}).get("sub") + user_id = user_id or client.user + user_id = user_id or data.get("clientUserId") or "" + # TODO: Need to see how can use the action ID supplied by the client. At the # moment we just log it. - user_id = client.user or data.get("clientUserId") or "" action_id = data.get("clientActionId") or "" # pylint: disable=unused-variable + index_url = data.get("clientIndexUrl") or "" workshop_name = data.get("workshopName") parameters = data.get("workshopParams", []) logger.info( - "Workshop request from client %r for tenant %r, workshop %r, user %r, action %r", + "Workshop request to client %r for tenant %r, workshop %r, user %r, action %r", client.name, tenant_name, workshop_name, diff --git a/lookup-service/testing/clientconfigs.yaml b/lookup-service/testing/clientconfigs.yaml index 35d2d61d..9b08004e 100644 --- a/lookup-service/testing/clientconfigs.yaml +++ b/lookup-service/testing/clientconfigs.yaml @@ -8,5 +8,49 @@ spec: password: super-secret roles: - admin + tenants: + - "*" +--- +apiVersion: lookup.educates.dev/v1beta1 +kind: ClientConfig +metadata: + name: client-1 + namespace: educates-config +spec: + client: + password: client-secret + roles: + - tenant + tenants: + - "tenant-1" +--- +apiVersion: lookup.educates.dev/v1beta1 +kind: ClientConfig +metadata: + name: client-2 + namespace: educates-config +spec: + client: + password: client-secret + proxy: + secret: proxy-secret + roles: + - tenant + tenants: + - "tenant-1" +--- +apiVersion: lookup.educates.dev/v1beta1 +kind: ClientConfig +metadata: + name: client-3 + namespace: educates-config +spec: + client: + password: client-secret + proxy: + issuer: "https://example.com" + secret: proxy-secret + roles: + - tenant tenants: - "tenant-1"