From 53ab737c158fe1a8e87ee80d5b7fc6983c901115 Mon Sep 17 00:00:00 2001 From: Frederick Muriuki Muriithi Date: Mon, 10 Jun 2024 12:29:51 -0500 Subject: Set default headers for OAuth2Client requests. --- gn2/wqflask/oauth2/client.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) (limited to 'gn2/wqflask/oauth2/client.py') diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py index 75e438cc..876ecf6b 100644 --- a/gn2/wqflask/oauth2/client.py +++ b/gn2/wqflask/oauth2/client.py @@ -70,12 +70,18 @@ def __no_token__(_err) -> Left: resp.status_code = 400 return Left(resp) -def oauth2_get(uri_path: str, data: dict = {}, - jsonify_p: bool = False, **kwargs) -> Either: +def oauth2_get( + uri_path: str, + data: dict = {}, + jsonify_p: bool = False, + headers: dict = {"Content-Type": "application/json"}, + **kwargs +) -> Either: def __get__(token) -> Either: resp = oauth2_client().get( urljoin(authserver_uri(), uri_path), data=data, + headers=headers, **kwargs) if resp.status_code == 200: if jsonify_p: @@ -87,11 +93,18 @@ def oauth2_get(uri_path: str, data: dict = {}, return session.user_token().either(__no_token__, __get__) def oauth2_post( - uri_path: str, data: Optional[dict] = None, json: Optional[dict] = None, - **kwargs) -> Either: + uri_path: str, + data: Optional[dict] = None, + json: Optional[dict] = None, + headers: dict = {"Content-Type": "application/json"}, + **kwargs +) -> Either: def __post__(token) -> Either: resp = oauth2_client().post( - urljoin(authserver_uri(), uri_path), data=data, json=json, + urljoin(authserver_uri(), uri_path), + data=data, + json=json, + headers=headers, **kwargs) if resp.status_code == 200: return Right(resp.json()) @@ -100,10 +113,14 @@ def oauth2_post( return session.user_token().either(__no_token__, __post__) -def no_token_get(uri_path: str, **kwargs) -> Either: +def no_token_get( + uri_path: str, + headers: dict = {"Content-Type": "application/json"}, + **kwargs +) -> Either: uri = urljoin(authserver_uri(), uri_path) try: - resp = requests.get(uri, **kwargs) + resp = requests.get(uri, headers=headers, **kwargs) if resp.status_code == 200: return Right(resp.json()) return Left(resp) -- cgit v1.2.3 From a6b237f7473b16f0d90a09983d5ec4a58d87f4ac Mon Sep 17 00:00:00 2001 From: Frederick Muriuki Muriithi Date: Wed, 17 Jul 2024 11:39:33 -0500 Subject: Fix premature session expiration With the change to JWTs the time-to-live for each token is severely curtailed to help with security in case of a token theft. We, therefore, can no longer rely on the TTL for session expiration, rather, we will rely of the token-refresh mechanism to expire a token after a long while. --- gn2/wqflask/oauth2/client.py | 7 +------ gn2/wqflask/oauth2/session.py | 7 ------- 2 files changed, 1 insertion(+), 13 deletions(-) (limited to 'gn2/wqflask/oauth2/client.py') diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py index 876ecf6b..770777b5 100644 --- a/gn2/wqflask/oauth2/client.py +++ b/gn2/wqflask/oauth2/client.py @@ -31,12 +31,7 @@ def oauth2_clientsecret(): def user_logged_in(): """Check whether the user has logged in.""" suser = session.session_info()["user"] - if suser["logged_in"]: - if session.expired(): - session.clear_session_info() - return False - return suser["token"].is_right() - return False + return suser["logged_in"] and suser["token"].is_right() def oauth2_client(): diff --git a/gn2/wqflask/oauth2/session.py b/gn2/wqflask/oauth2/session.py index 2ef534e2..eec48a7f 100644 --- a/gn2/wqflask/oauth2/session.py +++ b/gn2/wqflask/oauth2/session.py @@ -64,13 +64,6 @@ def session_info() -> SessionInfo: "masquerading": None })) -def expired(): - the_session = session_info() - def __expired__(token): - return datetime.now() > datetime.fromtimestamp(token["expires_at"]) - return the_session["user"]["token"].either( - lambda left: False, - __expired__) def set_user_token(token: str) -> SessionInfo: """Set the user's token.""" -- cgit v1.2.3 From 2ff7cf9ff8640328c5849c5ff07bd0113a303856 Mon Sep 17 00:00:00 2001 From: Frederick Muriuki Muriithi Date: Wed, 31 Jul 2024 15:27:59 -0500 Subject: Synchronise token refreshes The application can be run in a multi-threaded server, leading to a situation where the multiple threads attempt to get a new JWT using the exact same refresh token. This synchronises the various threads ensuring only a single thread is able to retrieve the new JWT that all the rest of the threads then use. --- gn2/wqflask/oauth2/client.py | 37 +++++++++++++++++++++++++++++++++---- gn2/wqflask/oauth2/session.py | 24 +++++++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) (limited to 'gn2/wqflask/oauth2/client.py') diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py index 770777b5..0d4615e8 100644 --- a/gn2/wqflask/oauth2/client.py +++ b/gn2/wqflask/oauth2/client.py @@ -1,5 +1,7 @@ """Common oauth2 client utilities.""" import json +import time +import random import requests from typing import Optional from urllib.parse import urljoin @@ -38,10 +40,36 @@ def oauth2_client(): def __update_token__(token, refresh_token=None, access_token=None): """Update the token when refreshed.""" session.set_user_token(token) + return token - def __client__(token) -> OAuth2Session: + def __validate_token__(token): _jwt = jwt.decode(token["access_token"], app.config["AUTH_SERVER_SSL_PUBLIC_KEY"]) + return token + + def __delay__(): + """Do a tiny delay.""" + time.sleep(random.choice(tuple(i/1000.0 for i in range(0,100)))) + + def __refresh_token__(token): + """Synchronise token refresh.""" + if session.is_token_expired(): + __delay__() + if session.is_token_refreshing(): + while session.is_token_refreshing(): + __delay__() + _token = session.user_token().either(None, lambda _tok: _tok) + return _token + + session.toggle_token_refreshing() + _client = __client__(token) + _client.get(urljoin(authserver_uri(), "auth/user/")) + session.toggle_token_refreshing() + return _client.token + + return token + + def __client__(token) -> OAuth2Session: client = OAuth2Session( oauth2_clientid(), oauth2_clientsecret(), @@ -51,9 +79,10 @@ def oauth2_client(): token=token, update_token=__update_token__) return client - return session.user_token().either( - lambda _notok: __client__(None), - lambda token: __client__(token)) + return session.user_token().then(__validate_token__).then( + __refresh_token__).either( + lambda _notok: __client__(None), + lambda token: __client__(token)) def __no_token__(_err) -> Left: """Handle situation where request is attempted with no token.""" diff --git a/gn2/wqflask/oauth2/session.py b/gn2/wqflask/oauth2/session.py index eec48a7f..92181ccf 100644 --- a/gn2/wqflask/oauth2/session.py +++ b/gn2/wqflask/oauth2/session.py @@ -22,6 +22,7 @@ class SessionInfo(TypedDict): user_agent: str ip_addr: str masquerade: Optional[UserDetails] + refreshing_token: bool __SESSION_KEY__ = "GN::2::session_info" # Do not use this outside this module!! @@ -61,7 +62,8 @@ def session_info() -> SessionInfo: "user_agent": request.headers.get("User-Agent"), "ip_addr": request.environ.get("HTTP_X_FORWARDED_FOR", request.remote_addr), - "masquerading": None + "masquerading": None, + "token_refreshing": False })) @@ -102,3 +104,23 @@ def unset_masquerading(): "user": the_session["masquerading"], "masquerading": None }) + + +def toggle_token_refreshing(): + """Toggle the state of the token_refreshing variable.""" + _session = session_info() + return save_session_info({ + **_session, + "token_refreshing": not _session.get("token_refreshing", False)}) + + +def is_token_expired(): + """Check whether the token is expired.""" + return user_token().either( + lambda _no_token: False, + lambda token: datetime.now().timestamp() > token["expires_at"]) + + +def is_token_refreshing(): + """Returns whether the token is being refreshed or not.""" + return session_info().get("token_refreshing", False) -- cgit v1.2.3 From a9a8ef79a10c58a514d5aac0b2b1c9000a57f9f8 Mon Sep 17 00:00:00 2001 From: Frederick Muriuki Muriithi Date: Thu, 1 Aug 2024 12:19:34 -0500 Subject: Use JWKs from auth server public endpoint * Fetch keys from auth server * Validate token is signed with one of the keys from server * Ensure refreshing of token is still synchronised --- gn2/wqflask/app_errors.py | 2 +- gn2/wqflask/oauth2/client.py | 95 +++++++++++++++++++++++++++++++++++++------ gn2/wqflask/oauth2/session.py | 8 +--- 3 files changed, 84 insertions(+), 21 deletions(-) (limited to 'gn2/wqflask/oauth2/client.py') diff --git a/gn2/wqflask/app_errors.py b/gn2/wqflask/app_errors.py index 7c07fde6..bafe773b 100644 --- a/gn2/wqflask/app_errors.py +++ b/gn2/wqflask/app_errors.py @@ -51,7 +51,7 @@ def handle_invalid_token_error(exc: InvalidTokenError): flash("An invalid session token was detected. " "You have been logged out of the system.", "alert-danger") - current_app.logger.error("Invalit token detected. %s", request.url, exc_info=True) + current_app.logger.error("Invalid token detected. %s", request.url, exc_info=True) session.clear_session_info() return redirect("/") diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py index 0d4615e8..6f137f52 100644 --- a/gn2/wqflask/oauth2/client.py +++ b/gn2/wqflask/oauth2/client.py @@ -5,10 +5,12 @@ import random import requests from typing import Optional from urllib.parse import urljoin +from datetime import datetime, timedelta from flask import current_app as app from pymonad.either import Left, Right, Either -from authlib.jose import jwt +from authlib.jose import KeySet, JsonWebKey, JsonWebToken +from authlib.jose.errors import BadSignatureError from authlib.integrations.requests_client import OAuth2Session from gn2.wqflask.oauth2 import session @@ -36,30 +38,96 @@ def user_logged_in(): return suser["logged_in"] and suser["token"].is_right() +def __make_token_validator__(keys: KeySet): + """Make a token validator function.""" + def __validator__(token: dict): + for key in keys.keys: + try: + # Fixes CVE-2016-10555. See + # https://docs.authlib.org/en/latest/jose/jwt.html + jwt = JsonWebToken(["RS256"]) + jwt.decode(token["access_token"], key) + return Right(token) + except BadSignatureError: + pass + + return Left("INVALID-TOKEN") + + return __validator__ + + +def auth_server_jwks() -> Optional[KeySet]: + """Fetch the auth-server JSON Web Keys information.""" + _jwks = session.session_info().get("auth_server_jwks") + if bool(_jwks): + return { + "last-updated": _jwks["last-updated"], + "jwks": KeySet([ + JsonWebKey.import_key(key) for key in _jwks.get( + "auth_server_jwks", {}).get( + "jwks", {"keys": []})["keys"]])} + + +def __validate_token__(keys): + """Validate that the token is really from the auth server.""" + def __index__(_sess): + return _sess + return session.user_token().then(__make_token_validator__(keys)).then( + session.set_user_token).either(__index__, __index__) + + +def __update_auth_server_jwks__(): + """Updates the JWKs every 2 hours or so.""" + jwks = auth_server_jwks() + if bool(jwks): + last_updated = jwks.get("last-updated") + now = datetime.now().timestamp() + if bool(last_updated) and (now - last_updated) < timedelta(hours=2).seconds: + return __validate_token__(jwks["jwks"]) + + jwksuri = urljoin(authserver_uri(), "auth/public-jwks") + jwks = KeySet([ + JsonWebKey.import_key(key) + for key in requests.get(jwksuri).json()["jwks"]]) + return __validate_token__(jwks) + + +def is_token_expired(token): + """Check whether the token has expired.""" + __update_auth_server_jwks__() + jwks = auth_server_jwks() + if bool(jwks): + for jwk in jwks["jwks"].keys: + try: + jwt = JsonWebToken(["RS256"]).decode( + token["access_token"], key=jwk) + return datetime.now().timestamp() > jwt["exp"] + except BadSignatureError as _bse: + pass + + return False + + def oauth2_client(): def __update_token__(token, refresh_token=None, access_token=None): """Update the token when refreshed.""" session.set_user_token(token) return token - def __validate_token__(token): - _jwt = jwt.decode(token["access_token"], - app.config["AUTH_SERVER_SSL_PUBLIC_KEY"]) - return token - def __delay__(): """Do a tiny delay.""" time.sleep(random.choice(tuple(i/1000.0 for i in range(0,100)))) def __refresh_token__(token): """Synchronise token refresh.""" - if session.is_token_expired(): + if is_token_expired(token): __delay__() if session.is_token_refreshing(): while session.is_token_refreshing(): __delay__() - _token = session.user_token().either(None, lambda _tok: _tok) - return _token + + _token = session.user_token().either(None, lambda _tok: _tok) + return _token session.toggle_token_refreshing() _client = __client__(token) @@ -79,10 +147,11 @@ def oauth2_client(): token=token, update_token=__update_token__) return client - return session.user_token().then(__validate_token__).then( - __refresh_token__).either( - lambda _notok: __client__(None), - lambda token: __client__(token)) + + __update_auth_server_jwks__() + return session.user_token().then(__refresh_token__).either( + lambda _notok: __client__(None), + lambda token: __client__(token)) def __no_token__(_err) -> Left: """Handle situation where request is attempted with no token.""" diff --git a/gn2/wqflask/oauth2/session.py b/gn2/wqflask/oauth2/session.py index 92181ccf..b91534b0 100644 --- a/gn2/wqflask/oauth2/session.py +++ b/gn2/wqflask/oauth2/session.py @@ -23,6 +23,7 @@ class SessionInfo(TypedDict): ip_addr: str masquerade: Optional[UserDetails] refreshing_token: bool + auth_server_jwks: Optional[dict[str, Any]] __SESSION_KEY__ = "GN::2::session_info" # Do not use this outside this module!! @@ -114,13 +115,6 @@ def toggle_token_refreshing(): "token_refreshing": not _session.get("token_refreshing", False)}) -def is_token_expired(): - """Check whether the token is expired.""" - return user_token().either( - lambda _no_token: False, - lambda token: datetime.now().timestamp() > token["expires_at"]) - - def is_token_refreshing(): """Returns whether the token is being refreshed or not.""" return session_info().get("token_refreshing", False) -- cgit v1.2.3 From c2c8d8298e3f10492cc26f76fe9d8e3053bcd4ef Mon Sep 17 00:00:00 2001 From: Frederick Muriuki Muriithi Date: Mon, 5 Aug 2024 17:19:34 -0500 Subject: Override 'client_secret_post' auth with a JSON equivalent In order to use JSON consistently across the board, we make even the authentication method use JSON rather than FORMDATA. --- gn2/wqflask/oauth2/client.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) (limited to 'gn2/wqflask/oauth2/client.py') diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py index 6f137f52..89d8a57e 100644 --- a/gn2/wqflask/oauth2/client.py +++ b/gn2/wqflask/oauth2/client.py @@ -9,8 +9,9 @@ from datetime import datetime, timedelta from flask import current_app as app from pymonad.either import Left, Right, Either -from authlib.jose import KeySet, JsonWebKey, JsonWebToken +from authlib.common.urls import url_decode from authlib.jose.errors import BadSignatureError +from authlib.jose import KeySet, JsonWebKey, JsonWebToken from authlib.integrations.requests_client import OAuth2Session from gn2.wqflask.oauth2 import session @@ -137,6 +138,16 @@ def oauth2_client(): return token + def __json_auth__(client, method, uri, headers, body): + return ( + uri, + {**headers, "Content-Type": "application/json"}, + json.dumps({ + **dict(url_decode(body)), + "client_id": oauth2_clientid(), + "client_secret": oauth2_clientsecret() + })) + def __client__(token) -> OAuth2Session: client = OAuth2Session( oauth2_clientid(), @@ -146,6 +157,8 @@ def oauth2_client(): token_endpoint_auth_method="client_secret_post", token=token, update_token=__update_token__) + client.register_client_auth_method( + ("client_secret_post", __json_auth__)) return client __update_auth_server_jwks__() -- cgit v1.2.3 From 1a4f0b1195cc7351589460487fc867935ba22aac Mon Sep 17 00:00:00 2001 From: Frederick Muriuki Muriithi Date: Mon, 5 Aug 2024 17:20:42 -0500 Subject: Fix URL --- gn2/wqflask/oauth2/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'gn2/wqflask/oauth2/client.py') diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py index 89d8a57e..a7d20f6b 100644 --- a/gn2/wqflask/oauth2/client.py +++ b/gn2/wqflask/oauth2/client.py @@ -153,7 +153,7 @@ def oauth2_client(): oauth2_clientid(), oauth2_clientsecret(), scope=SCOPE, - token_endpoint=urljoin(authserver_uri(), "/auth/token"), + token_endpoint=urljoin(authserver_uri(), "auth/token"), token_endpoint_auth_method="client_secret_post", token=token, update_token=__update_token__) -- cgit v1.2.3