Skip to content

Commit

Permalink
Add support for generating proxy tokens for lookup service client.
Browse files Browse the repository at this point in the history
  • Loading branch information
GrahamDumpleton committed Oct 21, 2024
1 parent d377c94 commit d9e3b3f
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ rules:
- apiGroups:
- lookup.educates.dev
resources:
- corsconfigs/finalizers
- clusterconfigs/finalizers
- clientconfigs/finalizers
- tenantconfigs/finalizers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 14 additions & 4 deletions lookup-service/service/caches/clientconfig.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Configuration for clients of the service."""

import datetime
import fnmatch
from dataclasses import dataclass
from typing import List, Set
Expand All @@ -11,22 +12,26 @@ class ClientConfig:

name: str
uid: str
issue: int
start: int
password: str
user: str
issuer: str
proxy: str
tenants: List[str]
roles: List[str]

@property
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."""
Expand All @@ -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."""
Expand Down
22 changes: 20 additions & 2 deletions lookup-service/service/handlers/clientconfigs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Operator handlers for client configuration resources."""

import datetime
import logging
from typing import Any, Dict

Expand All @@ -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(
Expand All @@ -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,
)
Expand Down
8 changes: 7 additions & 1 deletion lookup-service/service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 117 additions & 23 deletions lookup-service/service/routes/authnz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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",
)
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
Loading

0 comments on commit d9e3b3f

Please sign in to comment.