aboutsummaryrefslogtreecommitdiff
path: root/gn2/wqflask/oauth2
diff options
context:
space:
mode:
authorAlexander Kabui2024-08-30 16:47:55 +0300
committerGitHub2024-08-30 16:47:55 +0300
commited20621c23a9a41152f3d6a48334f2a31c018033 (patch)
tree5e2182b99f5f05e2f667dfce1b762921c4ec62dc /gn2/wqflask/oauth2
parent9a345d8abf2f0045b2c47bfcf1ae5860273452be (diff)
parent6db49002d4d2e69fcf4fdd6be6aceeea7b95664f (diff)
downloadgenenetwork2-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.py8
-rw-r--r--gn2/wqflask/oauth2/client.py157
-rw-r--r--gn2/wqflask/oauth2/data.py8
-rw-r--r--gn2/wqflask/oauth2/groups.py32
-rw-r--r--gn2/wqflask/oauth2/jwks.py86
-rw-r--r--gn2/wqflask/oauth2/request_utils.py2
-rw-r--r--gn2/wqflask/oauth2/resources.py240
-rw-r--r--gn2/wqflask/oauth2/roles.py76
-rw-r--r--gn2/wqflask/oauth2/session.py25
-rw-r--r--gn2/wqflask/oauth2/toplevel.py35
-rw-r--r--gn2/wqflask/oauth2/ui.py19
-rw-r--r--gn2/wqflask/oauth2/users.py28
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(