diff options
author | Alexander Kabui | 2024-08-30 16:47:55 +0300 |
---|---|---|
committer | GitHub | 2024-08-30 16:47:55 +0300 |
commit | ed20621c23a9a41152f3d6a48334f2a31c018033 (patch) | |
tree | 5e2182b99f5f05e2f667dfce1b762921c4ec62dc /gn2/wqflask/oauth2 | |
parent | 9a345d8abf2f0045b2c47bfcf1ae5860273452be (diff) | |
parent | 6db49002d4d2e69fcf4fdd6be6aceeea7b95664f (diff) | |
download | genenetwork2-ed20621c23a9a41152f3d6a48334f2a31c018033.tar.gz |
Merge pull request #861 from genenetwork/feature/gnqa-search-2
Feature/gnqa search 2
Diffstat (limited to 'gn2/wqflask/oauth2')
-rw-r--r-- | gn2/wqflask/oauth2/checks.py | 8 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/client.py | 157 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/data.py | 8 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/groups.py | 32 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/jwks.py | 86 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/request_utils.py | 2 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/resources.py | 240 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/roles.py | 76 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/session.py | 25 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/toplevel.py | 35 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/ui.py | 19 | ||||
-rw-r--r-- | gn2/wqflask/oauth2/users.py | 28 |
12 files changed, 509 insertions, 207 deletions
diff --git a/gn2/wqflask/oauth2/checks.py b/gn2/wqflask/oauth2/checks.py index 7f33348e..4a5a117f 100644 --- a/gn2/wqflask/oauth2/checks.py +++ b/gn2/wqflask/oauth2/checks.py @@ -2,12 +2,10 @@ from functools import wraps from urllib.parse import urljoin +from flask import flash, request, redirect from authlib.integrations.requests_client import OAuth2Session -from flask import ( - flash, request, redirect, session as flask_session) from . import session -from .session import clear_session_info from .client import ( oauth2_get, oauth2_client, @@ -24,8 +22,6 @@ def require_oauth2(func): def __clear_session__(_no_token): session.clear_session_info() - flask_session.pop("oauth2_token", None) - flask_session.pop("user_details", None) flash("You need to be logged in.", "alert-warning") return redirect("/") @@ -36,7 +32,7 @@ def require_oauth2(func): if not user_details.get("error", False): return func(*args, **kwargs) - return clear_session_info(token) + return __clear_session__(token) return session.user_token().either(__clear_session__, __with_token__) diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py index 75e438cc..a7d20f6b 100644 --- a/gn2/wqflask/oauth2/client.py +++ b/gn2/wqflask/oauth2/client.py @@ -1,12 +1,17 @@ """Common oauth2 client utilities.""" import json +import time +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.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 @@ -31,11 +36,76 @@ 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 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 @@ -43,20 +113,56 @@ 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 __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 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 + + session.toggle_token_refreshing() + _client = __client__(token) + _client.get(urljoin(authserver_uri(), "auth/user/")) + session.toggle_token_refreshing() + return _client.token + + 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: - _jwt = jwt.decode(token["access_token"], - app.config["AUTH_SERVER_SSL_PUBLIC_KEY"]) client = OAuth2Session( 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__) + client.register_client_auth_method( + ("client_secret_post", __json_auth__)) return client - return session.user_token().either( + + __update_auth_server_jwks__() + return session.user_token().then(__refresh_token__).either( lambda _notok: __client__(None), lambda token: __client__(token)) @@ -70,12 +176,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 +199,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 +219,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) diff --git a/gn2/wqflask/oauth2/data.py b/gn2/wqflask/oauth2/data.py index 29d68be0..16e5f60c 100644 --- a/gn2/wqflask/oauth2/data.py +++ b/gn2/wqflask/oauth2/data.py @@ -69,8 +69,10 @@ def __search_phenotypes__(query, template, **kwargs): template, traits=[], per_page=per_page, query=query, selected_traits=selected_traits, search_results=search_results, search_endpoint=urljoin( - authserver_uri(), "auth/data/search"), - gn_server_url = authserver_uri(), + request.host_url, "oauth2/data/phenotype/search"), + auth_server_url=authserver_uri(), + pheno_results_template=urljoin( + authserver_uri(), "auth/data/search/phenotype/<jobid>"), results_endpoint=urljoin( authserver_uri(), f"auth/data/search/phenotype/{job_id}"), @@ -122,6 +124,7 @@ def json_search_mrna() -> Response: @data.route("/phenotype/search", methods=["POST"]) def json_search_phenotypes() -> Response: """Search for phenotypes.""" + from gn2.utility.tools import GN_SERVER_URL form = request.json def __handle_error__(err): error = process_error(err) @@ -136,6 +139,7 @@ def json_search_phenotypes() -> Response: "per_page": int(form.get("per_page", 50)), "page": int(form.get("page", 1)), "auth_server_uri": authserver_uri(), + "gn3_server_uri": GN_SERVER_URL, "selected_traits": form.get("selected_traits", []) }).either(__handle_error__, jsonify) diff --git a/gn2/wqflask/oauth2/groups.py b/gn2/wqflask/oauth2/groups.py index 3bc4bcb2..b3f1f54d 100644 --- a/gn2/wqflask/oauth2/groups.py +++ b/gn2/wqflask/oauth2/groups.py @@ -44,7 +44,7 @@ def create_group(): def __setup_group__(response): session["user_details"]["group"] = response - resp = oauth2_post("auth/group/create", data=dict(request.form)) + resp = oauth2_post("auth/group/create", json=dict(request.form)) return resp.either( handle_error("oauth2.group.join_or_create"), handle_success( @@ -116,7 +116,7 @@ def accept_join_request(): return redirect(url_for("oauth2.group.list_join_requests")) return oauth2_post( "auth/group/requests/join/accept", - data=request.form).either( + json=dict(request.form)).either( handle_error("oauth2.group.list_join_requests"), __success__) @@ -132,34 +132,10 @@ def reject_join_request(): return redirect(url_for("oauth2.group.list_join_requests")) return oauth2_post( "auth/group/requests/join/reject", - data=request.form).either( + json=dict(request.form)).either( handle_error("oauth2.group.list_join_requests"), __success__) -@groups.route("/role/<uuid:group_role_id>", methods=["GET"]) -@require_oauth2 -def group_role(group_role_id: uuid.UUID): - """View the details of a particular role.""" - def __render_error__(**kwargs): - return render_ui("oauth2/view-group-role.html", **kwargs) - - def __gprivs_success__(role, group_privileges): - return render_ui( - "oauth2/view-group-role.html", group_role=role, - group_privileges=tuple( - priv for priv in group_privileges - if priv not in role["role"]["privileges"])) - - def __role_success__(role): - return oauth2_get("auth/group/privileges").either( - lambda err: __render_error__( - group_role=group_role, - group_privileges_error=process_error(err)), - lambda privileges: __gprivs_success__(role, privileges)) - - return oauth2_get(f"auth/group/role/{group_role_id}").either( - lambda err: __render_error__(group_role_error=process_error(err)), - __role_success__) def add_delete_privilege_to_role( group_role_id: uuid.UUID, direction: str) -> Response: @@ -187,7 +163,7 @@ def add_delete_privilege_to_role( } return oauth2_post( uris[direction], - data={ + json={ "group_role_id": group_role_id, "privilege_id": privilege_id }).either(__error__, __success__) diff --git a/gn2/wqflask/oauth2/jwks.py b/gn2/wqflask/oauth2/jwks.py new file mode 100644 index 00000000..efd04997 --- /dev/null +++ b/gn2/wqflask/oauth2/jwks.py @@ -0,0 +1,86 @@ +"""Utilities dealing with JSON Web Keys (JWK)""" +import os +from pathlib import Path +from typing import Any, Union +from datetime import datetime, timedelta + +from flask import Flask +from authlib.jose import JsonWebKey +from pymonad.either import Left, Right, Either + +def jwks_directory(app: Flask, configname: str) -> Path: + """Compute the directory where the JWKs are stored.""" + appsecretsdir = Path(app.config[configname]).parent + if appsecretsdir.exists() and appsecretsdir.is_dir(): + jwksdir = Path(appsecretsdir, "jwks/") + if not jwksdir.exists(): + jwksdir.mkdir() + return jwksdir + raise ValueError( + "The `appsecretsdir` value should be a directory that actually exists.") + + +def generate_and_save_private_key( + storagedir: Path, + kty: str = "RSA", + crv_or_size: Union[str, int] = 2048, + options: tuple[tuple[str, Any]] = (("iat", datetime.now().timestamp()),) +) -> JsonWebKey: + """Generate a private key and save to `storagedir`.""" + privatejwk = JsonWebKey.generate_key( + kty, crv_or_size, dict(options), is_private=True) + keyname = f"{privatejwk.thumbprint()}.private.pem" + with open(Path(storagedir, keyname), "wb") as pemfile: + pemfile.write(privatejwk.as_pem(is_private=True)) + + return privatejwk + + +def pem_to_jwk(filepath: Path) -> JsonWebKey: + """Parse a PEM file into a JWK object.""" + with open(filepath, "rb") as pemfile: + return JsonWebKey.import_key(pemfile.read()) + + +def __sorted_jwks_paths__(storagedir: Path) -> tuple[tuple[float, Path], ...]: + """A sorted list of the JWK file paths with their creation timestamps.""" + return tuple(sorted(((os.stat(keypath).st_ctime, keypath) + for keypath in (Path(storagedir, keyfile) + for keyfile in os.listdir(storagedir) + if keyfile.endswith(".pem"))), + key=lambda tpl: tpl[0])) + + +def list_jwks(storagedir: Path) -> tuple[JsonWebKey, ...]: + """ + List all the JWKs in a particular directory in the order they were created. + """ + return tuple(pem_to_jwk(keypath) for ctime,keypath in + __sorted_jwks_paths__(storagedir)) + + +def newest_jwk(storagedir: Path) -> Either: + """ + Return an Either monad with the newest JWK or a message if none exists. + """ + existingkeys = __sorted_jwks_paths__(storagedir) + if len(existingkeys) > 0: + return Right(pem_to_jwk(existingkeys[-1][1])) + return Left("No JWKs exist") + + +def newest_jwk_with_rotation(jwksdir: Path, keyage: int) -> JsonWebKey: + """ + Retrieve the latests JWK, creating a new one if older than `keyage` days. + """ + def newer_than_days(jwkey): + filestat = os.stat(Path( + jwksdir, f"{jwkey.as_dict()['kid']}.private.pem")) + oldesttimeallowed = (datetime.now() - timedelta(days=keyage)) + if filestat.st_ctime < (oldesttimeallowed.timestamp()): + return Left("JWK is too old!") + return jwkey + + return newest_jwk(jwksdir).then(newer_than_days).either( + lambda _errmsg: generate_and_save_private_key(jwksdir), + lambda key: key) diff --git a/gn2/wqflask/oauth2/request_utils.py b/gn2/wqflask/oauth2/request_utils.py index 1cdc465f..456aba2b 100644 --- a/gn2/wqflask/oauth2/request_utils.py +++ b/gn2/wqflask/oauth2/request_utils.py @@ -36,7 +36,7 @@ def process_error(error: Response, try: err = error.json() msg = err.get( - "error", err.get("error_description", f"{error.reason}")) + "error_message", err.get("error_description", f"{error.reason}")) except simplejson.errors.JSONDecodeError as _jde: msg = message return { diff --git a/gn2/wqflask/oauth2/resources.py b/gn2/wqflask/oauth2/resources.py index 32efbd2a..7ea7fe38 100644 --- a/gn2/wqflask/oauth2/resources.py +++ b/gn2/wqflask/oauth2/resources.py @@ -1,17 +1,25 @@ -import uuid +from uuid import UUID from flask import ( flash, request, url_for, redirect, Response, Blueprint) from . import client -from .ui import render_ui +from . import session +from .ui import render_ui as _render_ui from .checks import require_oauth2 from .client import oauth2_get, oauth2_post -from .request_utils import ( - flash_error, flash_success, request_error, process_error) +from .request_utils import (flash_error, + flash_success, + request_error, + process_error, + with_flash_error, + with_flash_success) resources = Blueprint("resource", __name__) +def render_ui(template, **kwargs): + return _render_ui(template, uipages="resources", **kwargs) + @resources.route("/", methods=["GET"]) @require_oauth2 def user_resources(): @@ -51,7 +59,7 @@ def create_resource(): flash("Resource created successfully", "alert-success") return redirect(url_for("oauth2.resource.user_resources")) return oauth2_post( - "auth/resource/create", data=request.form).either( + "auth/resource/create", json=dict(request.form)).either( __perr__, __psuc__) def __compute_page__(submit, current_page): @@ -59,47 +67,48 @@ def __compute_page__(submit, current_page): return current_page + 1 return (current_page - 1) or 1 -@resources.route("/view/<uuid:resource_id>", methods=["GET"]) +@resources.route("/<uuid:resource_id>/view", methods=["GET"]) @require_oauth2 -def view_resource(resource_id: uuid.UUID): +def view_resource(resource_id: UUID): """View the given resource.""" page = __compute_page__(request.args.get("submit"), int(request.args.get("page", "1"), base=10)) count_per_page = int(request.args.get("count_per_page", "100"), base=10) def __users_success__( - resource, unlinked_data, users_n_roles, this_user, group_roles, + resource, unlinked_data, users_n_roles, this_user, resource_roles, users): return render_ui( "oauth2/view-resource.html", resource=resource, unlinked_data=unlinked_data, users_n_roles=users_n_roles, - this_user=this_user, group_roles=group_roles, users=users, + this_user=this_user, resource_roles=resource_roles, users=users, page=page, count_per_page=count_per_page) - def __group_roles_success__( - resource, unlinked_data, users_n_roles, this_user, group_roles): + def __resource_roles_success__( + resource, unlinked_data, users_n_roles, this_user, resource_roles): return oauth2_get("auth/user/list").either( lambda err: render_ui( "oauth2/view-resource.html", resource=resource, unlinked_data=unlinked_data, users_n_roles=users_n_roles, - this_user=this_user, group_roles=group_roles, + this_user=this_user, resource_roles=resource_roles, users_error=process_error(err), count_per_page=count_per_page), lambda users: __users_success__( - resource, unlinked_data, users_n_roles, this_user, group_roles, + resource, unlinked_data, users_n_roles, this_user, resource_roles, users)) def __this_user_success__(resource, unlinked_data, users_n_roles, this_user): - return oauth2_get("auth/group/roles").either( + return oauth2_get(f"auth/resource/{resource_id}/roles").either( lambda err: render_ui( - "oauth2/view-resources.html", resource=resource, + "oauth2/view-resource.html", resource=resource, unlinked_data=unlinked_data, users_n_roles=users_n_roles, - this_user=this_user, group_roles_error=process_error(err)), - lambda groles: __group_roles_success__( - resource, unlinked_data, users_n_roles, this_user, groles)) + this_user=this_user, resource_roles_error=process_error(err), + count_per_page=count_per_page), + lambda rroles: __resource_roles_success__( + resource, unlinked_data, users_n_roles, this_user, rroles)) def __users_n_roles_success__(resource, unlinked_data, users_n_roles): return oauth2_get("auth/user/").either( lambda err: render_ui( - "oauth2/view-resources.html", + "oauth2/view-resource.html", this_user_error=process_error(err)), lambda usr_dets: __this_user_success__( resource, unlinked_data, users_n_roles, usr_dets)) @@ -120,8 +129,10 @@ def view_resource(resource_id: uuid.UUID): dataset_type = resource["resource_category"]["resource_category_key"] return oauth2_get(f"auth/group/{dataset_type}/unlinked-data").either( lambda err: render_ui( - "oauth2/view-resource.html", resource=resource, - unlinked_error=process_error(err)), + "oauth2/view-resource.html", + resource=resource, + unlinked_error=process_error(err), + count_per_page=count_per_page), lambda unlinked: __unlinked_success__(resource, unlinked)) def __fetch_resource_data__(resource): @@ -164,7 +175,7 @@ def link_data_to_resource(): flash(f"Data linked to resource successfully", "alert-success") return redirect(url_for( "oauth2.resource.view_resource", resource_id=resource_id)) - return oauth2_post("auth/resource/data/link", data=dict(form)).either( + return oauth2_post("auth/resource/data/link", json=dict(form)).either( __error__, __success__) except AssertionError as aserr: @@ -193,7 +204,7 @@ def unlink_data_from_resource(): return redirect(url_for( "oauth2.resource.view_resource", resource_id=resource_id)) return oauth2_post( - "auth/resource/data/unlink", data=dict(form)).either( + "auth/resource/data/unlink", json=dict(form)).either( __error__, __success__) except AssertionError as aserr: flash(aserr.args[0], "alert-danger") @@ -202,12 +213,12 @@ def unlink_data_from_resource(): @resources.route("<uuid:resource_id>/user/assign", methods=["POST"]) @require_oauth2 -def assign_role(resource_id: uuid.UUID) -> Response: +def assign_role(resource_id: UUID) -> Response: form = request.form - group_role_id = form.get("group_role_id", "") + role_id = form.get("role_id", "") user_email = form.get("user_email", "") try: - assert bool(group_role_id), "The role must be provided." + assert bool(role_id), "The role must be provided." assert bool(user_email), "The user email must be provided." def __assign_error__(error): @@ -223,22 +234,22 @@ def assign_role(resource_id: uuid.UUID) -> Response: return oauth2_post( f"auth/resource/{resource_id}/user/assign", - data={ - "group_role_id": group_role_id, + json={ + "role_id": role_id, "user_email": user_email }).either(__assign_error__, __assign_success__) except AssertionError as aserr: flash(aserr.args[0], "alert-danger") - return redirect(url_for("oauth2.resources.view_resource", resource_id=resource_id)) + return redirect(url_for("oauth2.resource.view_resource", resource_id=resource_id)) @resources.route("<uuid:resource_id>/user/unassign", methods=["POST"]) @require_oauth2 -def unassign_role(resource_id: uuid.UUID) -> Response: +def unassign_role(resource_id: UUID) -> Response: form = request.form - group_role_id = form.get("group_role_id", "") + role_id = form.get("role_id", "") user_id = form.get("user_id", "") try: - assert bool(group_role_id), "The role must be provided." + assert bool(role_id), "The role must be provided." assert bool(user_id), "The user id must be provided." def __unassign_error__(error): @@ -254,17 +265,17 @@ def unassign_role(resource_id: uuid.UUID) -> Response: return oauth2_post( f"auth/resource/{resource_id}/user/unassign", - data={ - "group_role_id": group_role_id, + json={ + "role_id": role_id, "user_id": user_id }).either(__unassign_error__, __unassign_success__) except AssertionError as aserr: flash(aserr.args[0], "alert-danger") - return redirect(url_for("oauth2.resources.view_resource", resource_id=resource_id)) + return redirect(url_for("oauth2.resource.view_resource", resource_id=resource_id)) @resources.route("/toggle/<uuid:resource_id>", methods=["POST"]) @require_oauth2 -def toggle_public(resource_id: uuid.UUID): +def toggle_public(resource_id: UUID): """Toggle the given resource's public status.""" def __handle_error__(err): flash_error(process_error(err)) @@ -277,18 +288,169 @@ def toggle_public(resource_id: uuid.UUID): "oauth2.resource.view_resource", resource_id=resource_id)) return oauth2_post( - f"auth/resource/{resource_id}/toggle-public", data={}).either( + f"auth/resource/{resource_id}/toggle-public").either( lambda err: __handle_error__(err), lambda suc: __handle_success__(suc)) @resources.route("/edit/<uuid:resource_id>", methods=["GET"]) @require_oauth2 -def edit_resource(resource_id: uuid.UUID): +def edit_resource(resource_id: UUID): """Edit the given resource.""" return "WOULD Edit THE GIVEN RESOURCE'S DETAILS" @resources.route("/delete/<uuid:resource_id>", methods=["GET"]) @require_oauth2 -def delete_resource(resource_id: uuid.UUID): +def delete_resource(resource_id: UUID): """Delete the given resource.""" return "WOULD DELETE THE GIVEN RESOURCE" + +@resources.route("/<uuid:resource_id>/roles/<uuid:role_id>", methods=["GET"]) +@require_oauth2 +def view_resource_role(resource_id: UUID, role_id: UUID): + """View resource role page.""" + def __render_template__(**kwargs): + return render_ui("oauth2/view-resource-role.html", **kwargs) + + def __fetch_users__(resource, role, unassigned_privileges): + return oauth2_get( + f"auth/resource/{resource_id}/role/{role_id}/users").either( + lambda error: __render_template__( + resource=resource, + role=role, + unassigned_privileges=unassigned_privileges, + user_error=process_error(error)), + lambda users: __render_template__( + resource=resource, + role=role, + unassigned_privileges=unassigned_privileges, + users=users)) + + def __fetch_all_roles__(resource, role): + return oauth2_get(f"auth/resource/{resource_id}/roles").either( + lambda error: __render_template__( + all_roles_error=process_error(error)), + lambda all_roles: __fetch_users__( + resource=resource, + role=role, + unassigned_privileges=[ + priv for role in all_roles + for priv in role["privileges"] + if priv not in role["privileges"] + ])) + + def __fetch_resource_role__(resource): + return oauth2_get( + f"auth/resource/{resource_id}/role/{role_id}").either( + lambda error: __render_template__( + resource=resource, + role_id=role_id, + role_error=process_error(error)), + lambda role: __fetch_all_roles__(resource, role)) + + return oauth2_get( + f"auth/resource/view/{resource_id}").either( + lambda error: __render_template__( + resource_error=process_error(error)), + lambda resource: __fetch_resource_role__(resource=resource)) + +@resources.route("/<uuid:resource_id>/roles/<uuid:role_id>/unassign-privilege", + methods=["GET", "POST"]) +@require_oauth2 +def unassign_privilege_from_resource_role(resource_id: UUID, role_id: UUID): + """Remove a privilege from a resource role.""" + form = request.form + returnto = redirect(url_for("oauth2.resource.view_resource_role", + resource_id=resource_id, + role_id=role_id)) + privilege_id = (request.args.get("privilege_id") + or form.get("privilege_id")) + if not privilege_id: + flash("You need to specify a privilege to unassign.", "alert-danger") + return returnto + + if request.method=="POST" and form.get("confirm") == "Unassign": + return oauth2_post( + f"auth/resource/{resource_id}/role/{role_id}/unassign-privilege", + json={ + "privilege_id": form["privilege_id"] + }).either(with_flash_error(returnto), with_flash_success(returnto)) + + if form.get("confirm") == "Cancel": + flash("Cancelled the operation to unassign the privilege.", + "alert-info") + return returnto + + def __fetch_privilege__(resource, role): + return oauth2_get( + f"auth/privileges/{privilege_id}/view").either( + with_flash_error(returnto), + lambda privilege: render_ui( + "oauth2/confirm-resource-role-unassign-privilege.html", + resource=resource, + role=role, + privilege=privilege)) + + def __fetch_resource_role__(resource): + return oauth2_get( + f"auth/resource/{resource_id}/role/{role_id}").either( + with_flash_error(returnto), + lambda role: __fetch_privilege__(resource, role)) + + return oauth2_get( + f"auth/resource/view/{resource_id}").either( + with_flash_error(returnto), + __fetch_resource_role__) + + +@resources.route("/<uuid:resource_id>/roles/create-role", + methods=["GET", "POST"]) +@require_oauth2 +def create_resource_role(resource_id: UUID): + """Create new role for the resource.""" + def __render__(**kwargs): + return render_ui("oauth2/create-role.html", **kwargs) + + def __fetch_resource_roles__(resource): + user = session.session_info()["user"] + return oauth2_get( + f"auth/resource/{resource_id}/users/{user['user_id']}" + "/roles").either( + lambda error: { + "resource": resource, + "resource_role_error": process_error(error) + }, + lambda roles: {"resource": resource, "roles": roles}) + + if request.method == "GET": + return oauth2_get(f"auth/resource/view/{resource_id}").map( + __fetch_resource_roles__).either( + lambda error: __render__(resource_error=error), + lambda kwargs: __render__(**kwargs)) + + formdata = request.form + privileges = formdata.getlist("privileges[]") + if not bool(privileges): + flash( + "You must provide at least one privilege for creation of the new " + "role.", + "alert-danger") + return redirect(url_for("oauth2.resource.create_resource_role", + resource_id=resource_id)) + + def __handle_error__(error): + flash_error(process_error(error)) + return redirect(url_for( + "oauth2.resource.create_resource_role", resource_id=resource_id)) + + def __handle_success__(success): + flash("Role successfully created.", "alert-success") + return redirect(url_for( + "oauth2.resource.view_resource", resource_id=resource_id)) + + return oauth2_post( + f"auth/resource/{resource_id}/roles/create", + json={ + "role_name": formdata["role_name"], + "privileges": privileges + }).either( + __handle_error__, __handle_success__) diff --git a/gn2/wqflask/oauth2/roles.py b/gn2/wqflask/oauth2/roles.py index 2fe35f9b..2a21670e 100644 --- a/gn2/wqflask/oauth2/roles.py +++ b/gn2/wqflask/oauth2/roles.py @@ -10,31 +10,6 @@ from .request_utils import request_error, process_error roles = Blueprint("role", __name__) -@roles.route("/user", methods=["GET"]) -@require_oauth2 -def user_roles(): - def __grerror__(roles, user_privileges, error): - return render_ui( - "oauth2/list_roles.html", roles=roles, - user_privileges=user_privileges, - group_roles_error=process_error(error)) - - def __grsuccess__(roles, user_privileges, group_roles): - return render_ui( - "oauth2/list_roles.html", roles=roles, - user_privileges=user_privileges, group_roles=group_roles) - - def __role_success__(roles): - uprivs = tuple( - privilege["privilege_id"] for role in roles - for privilege in role["privileges"]) - return oauth2_get("auth/group/roles").either( - lambda err: __grerror__(roles, uprivs, err), - lambda groles: __grsuccess__(roles, uprivs, groles)) - - return oauth2_get("auth/system/roles").either( - request_error, __role_success__) - @roles.route("/role/<uuid:role_id>", methods=["GET"]) @require_oauth2 def role(role_id: uuid.UUID): @@ -46,54 +21,3 @@ def role(role_id: uuid.UUID): return oauth2_get(f"auth/role/view/{role_id}").either( request_error, __success__) -@roles.route("/create", methods=["GET", "POST"]) -@require_oauth2 -def create_role(): - """Create a new role.""" - def __roles_error__(error): - return render_ui( - "oauth2/create-role.html", roles_error=process_error(error)) - - def __gprivs_error__(roles, error): - return render_ui( - "oauth2/create-role.html", roles=roles, - group_privileges_error=process_error(error)) - - def __success__(roles, gprivs): - uprivs = tuple( - privilege["privilege_id"] for role in roles - for privilege in role["privileges"]) - return render_ui( - "oauth2/create-role.html", roles=roles, user_privileges=uprivs, - group_privileges=gprivs, - prev_role_name=request.args.get("role_name")) - - def __fetch_gprivs__(roles): - return oauth2_get("auth/group/privileges").either( - lambda err: __gprivs_error__(roles, err), - lambda gprivs: __success__(roles, gprivs)) - - if request.method == "GET": - return oauth2_get("auth/user/roles").either( - __roles_error__, __fetch_gprivs__) - - form = request.form - role_name = form.get("role_name") - privileges = form.getlist("privileges[]") - if len(privileges) == 0: - flash("You must assign at least one privilege to the role", - "alert-danger") - return redirect(url_for( - "oauth2.role.create_role", role_name=role_name)) - def __create_error__(error): - err = process_error(error) - flash(f"{err['error']}: {err['error_description']}", - "alert-danger") - return redirect(url_for("oauth2.role.create_role")) - def __create_success__(*args): - flash("Role created successfully.", "alert-success") - return redirect(url_for("oauth2.role.user_roles")) - return oauth2_post( - "auth/group/role/create",data={ - "role_name": role_name, "privileges[]": privileges}).either( - __create_error__,__create_success__) diff --git a/gn2/wqflask/oauth2/session.py b/gn2/wqflask/oauth2/session.py index 2ef534e2..b91534b0 100644 --- a/gn2/wqflask/oauth2/session.py +++ b/gn2/wqflask/oauth2/session.py @@ -22,6 +22,8 @@ class SessionInfo(TypedDict): user_agent: str 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!! @@ -61,16 +63,10 @@ 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 })) -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.""" @@ -109,3 +105,16 @@ 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_refreshing(): + """Returns whether the token is being refreshed or not.""" + return session_info().get("token_refreshing", False) diff --git a/gn2/wqflask/oauth2/toplevel.py b/gn2/wqflask/oauth2/toplevel.py index 23965cc1..24d60311 100644 --- a/gn2/wqflask/oauth2/toplevel.py +++ b/gn2/wqflask/oauth2/toplevel.py @@ -3,11 +3,17 @@ import uuid import datetime from urllib.parse import urljoin, urlparse, urlunparse -from authlib.jose import jwt -from flask import ( - flash, request, Blueprint, url_for, redirect, render_template, - current_app as app) +from authlib.jose import jwt, KeySet +from flask import (flash, + request, + url_for, + jsonify, + redirect, + Blueprint, + render_template, + current_app as app) +from . import jwks from . import session from .checks import require_oauth2 from .request_utils import user_details, process_error @@ -29,7 +35,9 @@ def authorisation_code(): code = request.args.get("code", "") if bool(code): base_url = urlparse(request.base_url, scheme=request.scheme) - jwtkey = app.config["SSL_PRIVATE_KEY"] + jwtkey = jwks.newest_jwk_with_rotation( + jwks.jwks_directory(app, "GN2_SECRETS"), + int(app.config["JWKS_ROTATION_AGE_DAYS"])) issued = datetime.datetime.now() request_data = { "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", @@ -47,7 +55,7 @@ def authorisation_code(): "iss": str(oauth2_clientid()), "sub": request.args["user_id"], "aud": urljoin(authserver_uri(), "auth/token"), - "exp": (issued + datetime.timedelta(minutes=5)), + "exp": (issued + datetime.timedelta(minutes=5)).timestamp(), "nbf": int(issued.timestamp()), "iat": int(issued.timestamp()), "jti": str(uuid.uuid4())}, @@ -75,8 +83,17 @@ def authorisation_code(): }) return redirect("/") - return no_token_post( - "auth/token", data=request_data).either( - lambda err: __error__(process_error(err)), __success__) + return no_token_post("auth/token", json=request_data).either( + lambda err: __error__(process_error(err)), __success__) flash("AuthorisationError: No code was provided.", "alert-danger") return redirect("/") + + +@toplevel.route("/public-jwks", methods=["GET"]) +def public_jwks(): + """Provide endpoint that returns the public keys.""" + return jsonify({ + "documentation": "The keys are listed in order of creation.", + "jwks": KeySet(jwks.list_jwks( + jwks.jwks_directory(app, "GN2_SECRETS"))).as_dict().get("keys") + }) diff --git a/gn2/wqflask/oauth2/ui.py b/gn2/wqflask/oauth2/ui.py index cf2e9af7..04095420 100644 --- a/gn2/wqflask/oauth2/ui.py +++ b/gn2/wqflask/oauth2/ui.py @@ -1,22 +1,17 @@ """UI utilities""" -from flask import session, render_template +from flask import render_template from .client import oauth2_get -from .client import user_logged_in -from .request_utils import process_error def render_ui(templatepath: str, **kwargs): """Handle repetitive UI rendering stuff.""" - roles = kwargs.get("roles", tuple()) # Get roles if already provided - if user_logged_in() and not bool(roles): # If not, try fetching them - roles_results = oauth2_get("auth/system/roles").either( - lambda err: {"roles_error": process_error(err)}, - lambda roles: {"roles": roles}) - kwargs = {**kwargs, **roles_results} + roles = kwargs.get("roles", tuple()) # Get roles + if not roles: + roles = oauth2_get("auth/system/roles").either( + lambda _err: roles, lambda auth_roles: auth_roles) user_privileges = tuple( - privilege["privilege_id"] for role in roles - for privilege in role["privileges"]) + privilege["privilege_id"] for role in roles for privilege in role["privileges"]) kwargs = { - **kwargs, "roles": roles, "user_privileges": user_privileges + **kwargs, "roles": roles, "user_privileges": user_privileges, } return render_template(templatepath, **kwargs) diff --git a/gn2/wqflask/oauth2/users.py b/gn2/wqflask/oauth2/users.py index 8a935170..7d9186ab 100644 --- a/gn2/wqflask/oauth2/users.py +++ b/gn2/wqflask/oauth2/users.py @@ -1,6 +1,6 @@ import requests from uuid import UUID -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse from authlib.integrations.base_client.errors import OAuthError from flask import ( @@ -11,10 +11,16 @@ from . import client from . import session from .ui import render_ui from .checks import require_oauth2 -from .client import (oauth2_get, oauth2_post, oauth2_client, - authserver_uri, user_logged_in) -from .request_utils import ( - user_details, request_error, process_error, with_flash_error) +from .client import (oauth2_get, + oauth2_post, + oauth2_client, + authserver_uri, + user_logged_in) +from .request_utils import (user_details, + request_error, + process_error, + with_flash_error, + authserver_authorise_uri) users = Blueprint("user", __name__) @@ -61,7 +67,7 @@ def request_add_to_group() -> Response: return redirect(url_for("oauth2.user.user_profile")) return oauth2_post(f"auth/group/requests/join/{group_id}", - data=form).either(__error__, __success__) + json=form).either(__error__, __success__) @users.route("/logout", methods=["GET", "POST"]) @@ -84,7 +90,8 @@ def logout(): f"{the_session['masquerading']['name']} " f"({the_session['masquerading']['email']})", "alert-success") - return redirect("/") + + return redirect("/") @users.route("/register", methods=["GET", "POST"]) def register_user(): @@ -101,11 +108,14 @@ def register_user(): form = request.form response = requests.post( urljoin(authserver_uri(), "auth/user/register"), - data = { + json = { "user_name": form.get("user_name"), "email": form.get("email_address"), "password": form.get("password"), - "confirm_password": form.get("confirm_password")}) + "confirm_password": form.get("confirm_password"), + **dict( + item.split("=") for item in + urlparse(authserver_authorise_uri()).query.split("&"))}) results = response.json() if "error" in results: error_messages = tuple( |