aboutsummaryrefslogtreecommitdiff
path: root/gn2/wqflask/oauth2
diff options
context:
space:
mode:
authorArun Isaac2023-12-29 18:55:37 +0000
committerArun Isaac2023-12-29 19:01:46 +0000
commit204a308be0f741726b9a620d88fbc22b22124c81 (patch)
treeb3cf66906674020b530c844c2bb4982c8a0e2d39 /gn2/wqflask/oauth2
parent83062c75442160427b50420161bfcae2c5c34c84 (diff)
downloadgenenetwork2-204a308be0f741726b9a620d88fbc22b22124c81.tar.gz
Namespace all modules under gn2.
We move all modules under a gn2 directory. This is important for "correct" packaging and deployment as a Guix service.
Diffstat (limited to 'gn2/wqflask/oauth2')
-rw-r--r--gn2/wqflask/oauth2/__init__.py0
-rw-r--r--gn2/wqflask/oauth2/checks.py49
-rw-r--r--gn2/wqflask/oauth2/client.py124
-rw-r--r--gn2/wqflask/oauth2/collections.py16
-rw-r--r--gn2/wqflask/oauth2/data.py319
-rw-r--r--gn2/wqflask/oauth2/groups.py210
-rw-r--r--gn2/wqflask/oauth2/request_utils.py99
-rw-r--r--gn2/wqflask/oauth2/resources.py294
-rw-r--r--gn2/wqflask/oauth2/roles.py99
-rw-r--r--gn2/wqflask/oauth2/routes.py18
-rw-r--r--gn2/wqflask/oauth2/session.py111
-rw-r--r--gn2/wqflask/oauth2/toplevel.py57
-rw-r--r--gn2/wqflask/oauth2/ui.py23
-rw-r--r--gn2/wqflask/oauth2/users.py190
14 files changed, 1609 insertions, 0 deletions
diff --git a/gn2/wqflask/oauth2/__init__.py b/gn2/wqflask/oauth2/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/gn2/wqflask/oauth2/__init__.py
diff --git a/gn2/wqflask/oauth2/checks.py b/gn2/wqflask/oauth2/checks.py
new file mode 100644
index 00000000..5d90f986
--- /dev/null
+++ b/gn2/wqflask/oauth2/checks.py
@@ -0,0 +1,49 @@
+"""Various checkers for OAuth2"""
+from functools import wraps
+from urllib.parse import urljoin
+
+from authlib.integrations.requests_client import OAuth2Session
+from flask import (
+ flash, request, url_for, redirect, current_app, session as flask_session)
+
+from . import session
+
+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
+
+def require_oauth2(func):
+ """Decorator for ensuring user is logged in."""
+ @wraps(func)
+ def __token_valid__(*args, **kwargs):
+ """Check that the user is logged in and their token is valid."""
+ config = current_app.config
+ 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("/")
+
+ def __with_token__(token):
+ from gn2.utility.tools import (
+ AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+ client = OAuth2Session(
+ OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, token=token)
+ resp = client.get(
+ urljoin(AUTH_SERVER_URL, "auth/user/"))
+ user_details = resp.json()
+ if not user_details.get("error", False):
+ return func(*args, **kwargs)
+
+ return clear_session_info(token)
+
+ return session.user_token().either(__clear_session__, __with_token__)
+
+ return __token_valid__
diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py
new file mode 100644
index 00000000..c6a3110b
--- /dev/null
+++ b/gn2/wqflask/oauth2/client.py
@@ -0,0 +1,124 @@
+"""Common oauth2 client utilities."""
+import json
+import requests
+from typing import Any, Optional
+from urllib.parse import urljoin
+
+from flask import jsonify, current_app as app
+from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.either import Left, Right, Either
+from authlib.integrations.requests_client import OAuth2Session
+
+from gn2.wqflask.oauth2 import session
+from gn2.wqflask.oauth2.checks import user_logged_in
+
+SCOPE = ("profile group role resource register-client user masquerade "
+ "introspect migrate-data")
+
+def oauth2_client():
+ def __client__(token) -> OAuth2Session:
+ from gn2.utility.tools import (
+ AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+ return OAuth2Session(
+ OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET,
+ scope=SCOPE, token_endpoint_auth_method="client_secret_post",
+ token=token)
+ return session.user_token().either(
+ lambda _notok: __client__(None),
+ lambda token: __client__(token))
+
+def __no_token__(_err) -> Left:
+ """Handle situation where request is attempted with no token."""
+ resp = requests.models.Response()
+ resp._content = json.dumps({
+ "error": "AuthenticationError",
+ "error-description": ("You need to authenticate to access requested "
+ "information.")}).encode("utf-8")
+ resp.status_code = 400
+ return Left(resp)
+
+def oauth2_get(uri_path: str, data: dict = {}, **kwargs) -> Either:
+ def __get__(token) -> Either:
+ from gn2.utility.tools import (
+ AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+ client = OAuth2Session(
+ OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET,
+ token=token, scope=SCOPE)
+ resp = client.get(
+ urljoin(AUTH_SERVER_URL, uri_path),
+ data=data,
+ **kwargs)
+ if resp.status_code == 200:
+ return Right(resp.json())
+
+ return Left(resp)
+
+ return session.user_token().either(__no_token__, __get__)
+
+def oauth2_post(
+ uri_path: str, data: Optional[dict] = None, json: Optional[dict] = None,
+ **kwargs) -> Either:
+ def __post__(token) -> Either:
+ from gn2.utility.tools import (
+ AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+ client = OAuth2Session(
+ OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET,
+ token=token, scope=SCOPE)
+ resp = client.post(
+ urljoin(AUTH_SERVER_URL, uri_path), data=data, json=json,
+ **kwargs)
+ if resp.status_code == 200:
+ return Right(resp.json())
+
+ return Left(resp)
+
+ return session.user_token().either(__no_token__, __post__)
+
+def no_token_get(uri_path: str, **kwargs) -> Either:
+ from gn2.utility.tools import AUTH_SERVER_URL
+ resp = requests.get(urljoin(AUTH_SERVER_URL, uri_path), **kwargs)
+ if resp.status_code == 200:
+ return Right(resp.json())
+ return Left(resp)
+
+def no_token_post(uri_path: str, **kwargs) -> Either:
+ from gn2.utility.tools import (
+ AUTH_SERVER_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
+ data = kwargs.get("data", {})
+ the_json = kwargs.get("json", {})
+ request_data = {
+ **data,
+ **the_json,
+ "client_id": OAUTH2_CLIENT_ID,
+ "client_secret": OAUTH2_CLIENT_SECRET
+ }
+ new_kwargs = {
+ **{
+ key: value for key, value in kwargs.items()
+ if key not in ("data", "json")
+ },
+ ("data" if bool(data) else "json"): request_data
+ }
+ resp = requests.post(urljoin(AUTH_SERVER_URL, uri_path),
+ **new_kwargs)
+ if resp.status_code == 200:
+ return Right(resp.json())
+ return Left(resp)
+
+def post(uri_path: str, **kwargs) -> Either:
+ """
+ Generic function to do POST requests, that checks whether or not the user is
+ logged in and selects the appropriate function/method to run.
+ """
+ if user_logged_in():
+ return oauth2_post(uri_path, **kwargs)
+ return no_token_post(uri_path, **kwargs)
+
+def get(uri_path: str, **kwargs) -> Either:
+ """
+ Generic function to do GET requests, that checks whether or not the user is
+ logged in and selects the appropriate function/method to run.
+ """
+ if user_logged_in():
+ return oauth2_get(uri_path, **kwargs)
+ return no_token_get(uri_path, **kwargs)
diff --git a/gn2/wqflask/oauth2/collections.py b/gn2/wqflask/oauth2/collections.py
new file mode 100644
index 00000000..63bf206e
--- /dev/null
+++ b/gn2/wqflask/oauth2/collections.py
@@ -0,0 +1,16 @@
+"""Functions for collections."""
+from .session import session_info
+from .checks import user_logged_in
+from .client import oauth2_get, no_token_get
+
+def num_collections() -> int:
+ """Compute the number of collections available for the current session."""
+ anon_id = session_info()["anon_id"]
+ all_collections = no_token_get(
+ f"auth/user/collections/{anon_id}/list").either(
+ lambda _err: [], lambda colls: colls)
+ if user_logged_in():
+ all_collections = all_collections + oauth2_get(
+ "auth/user/collections/list").either(
+ lambda _err: [], lambda colls: colls)
+ return len(all_collections)
diff --git a/gn2/wqflask/oauth2/data.py b/gn2/wqflask/oauth2/data.py
new file mode 100644
index 00000000..a1dfdf95
--- /dev/null
+++ b/gn2/wqflask/oauth2/data.py
@@ -0,0 +1,319 @@
+"""Handle linking data to groups."""
+import sys
+import json
+import uuid
+from datetime import datetime
+from urllib.parse import urljoin
+
+from redis import Redis
+from flask import (
+ flash, request, jsonify, url_for, redirect, Response, Blueprint,
+ current_app as app)
+
+from gn2.wqflask.oauth2.request_utils import with_flash_error
+
+from gn2.jobs import jobs
+from .ui import render_ui
+from .request_utils import process_error
+from .client import oauth2_get, oauth2_post
+
+data = Blueprint("data", __name__)
+
+def __search_mrna__(query, template, **kwargs):
+ from gn2.utility.tools import AUTH_SERVER_URL
+ species_name = kwargs["species_name"]
+ search_uri = urljoin(AUTH_SERVER_URL, "auth/data/search")
+ datasets = oauth2_get(
+ "auth/data/search",
+ json = {
+ "query": query,
+ "dataset_type": "mrna",
+ "species_name": species_name,
+ "selected": __selected_datasets__()
+ }).either(
+ lambda err: {"datasets_error": process_error(err)},
+ lambda datasets: {"datasets": datasets})
+ return render_ui(template, search_uri=search_uri, **datasets, **kwargs)
+
+def __selected_datasets__():
+ if bool(request.json):
+ return request.json.get(
+ "selected",
+ request.args.get("selected",
+ request.form.get("selected", [])))
+ return request.args.get("selected",
+ request.form.get("selected", []))
+
+def __search_genotypes__(query, template, **kwargs):
+ from gn2.utility.tools import AUTH_SERVER_URL
+ species_name = kwargs["species_name"]
+ search_uri = urljoin(AUTH_SERVER_URL, "auth/data/search")
+ datasets = oauth2_get(
+ "auth/data/search",
+ json = {
+ "query": query,
+ "dataset_type": "genotype",
+ "species_name": species_name,
+ "selected": __selected_datasets__()
+ }).either(
+ lambda err: {"datasets_error": process_error(err)},
+ lambda datasets: {"datasets": datasets})
+ return render_ui(template, search_uri=search_uri, **datasets, **kwargs)
+
+def __search_phenotypes__(query, template, **kwargs):
+ from gn2.utility.tools import GN_SERVER_URL, AUTH_SERVER_URL
+ page = int(request.args.get("page", 1))
+ per_page = int(request.args.get("per_page", 50))
+ selected_traits = request.form.getlist("selected_traits")
+ def __search_success__(search_results):
+ job_id = uuid.UUID(search_results["job_id"])
+ return render_ui(
+ template, traits=[], per_page=per_page, query=query,
+ selected_traits=selected_traits, search_results=search_results,
+ search_endpoint=urljoin(
+ AUTH_SERVER_URL, "auth/data/search"),
+ gn_server_url = AUTH_SERVER_URL,
+ results_endpoint=urljoin(
+ AUTH_SERVER_URL,
+ f"auth/data/search/phenotype/{job_id}"),
+ **kwargs)
+ return oauth2_get("auth/data/search", json={
+ "dataset_type": "phenotype",
+ "species_name": kwargs["species_name"],
+ "per_page": per_page,
+ "page": page,
+ "gn3_server_uri": GN_SERVER_URL
+ }).either(
+ with_flash_error(redirect(url_for('oauth2.data.list_data'))),
+ __search_success__)
+
+@data.route("/genotype/search", methods=["POST"])
+def json_search_genotypes() -> Response:
+ def __handle_error__(err):
+ error = process_error(err)
+ return jsonify(error), error["status_code"]
+
+ return oauth2_get(
+ "auth/data/search",
+ json = {
+ "query": request.json["query"],
+ "dataset_type": "genotype",
+ "species_name": request.json["species_name"],
+ "selected": __selected_datasets__()
+ }).either(
+ __handle_error__,
+ lambda datasets: jsonify(datasets))
+
+@data.route("/mrna/search", methods=["POST"])
+def json_search_mrna() -> Response:
+ def __handle_error__(err):
+ error = process_error(err)
+ return jsonify(error), error["status_code"]
+
+ return oauth2_get(
+ "auth/data/search",
+ json = {
+ "query": request.json["query"],
+ "dataset_type": "mrna",
+ "species_name": request.json["species_name"],
+ "selected": __selected_datasets__()
+ }).either(
+ __handle_error__,
+ lambda datasets: jsonify(datasets))
+
+@data.route("/phenotype/search", methods=["POST"])
+def json_search_phenotypes() -> Response:
+ """Search for phenotypes."""
+ from gn2.utility.tools import AUTH_SERVER_URL
+ form = request.json
+ def __handle_error__(err):
+ error = process_error(err)
+ return jsonify(error), error["status_code"]
+
+ return oauth2_get(
+ "auth/data/search",
+ json={
+ "dataset_type": "phenotype",
+ "species_name": form["species_name"],
+ "query": form.get("query", ""),
+ "per_page": int(form.get("per_page", 50)),
+ "page": int(form.get("page", 1)),
+ "auth_server_uri": AUTH_SERVER_URL,
+ "selected_traits": form.get("selected_traits", [])
+ }).either(__handle_error__, jsonify)
+
+@data.route("/<string:species_name>/<string:dataset_type>/list",
+ methods=["GET", "POST"])
+def list_data_by_species_and_dataset(
+ species_name: str, dataset_type: str) -> Response:
+ templates = {
+ "mrna": "oauth2/data-list-mrna.html",
+ "genotype": "oauth2/data-list-genotype.html",
+ "phenotype": "oauth2/data-list-phenotype.html"
+ }
+ search_fns = {
+ "mrna": __search_mrna__,
+ "genotype": __search_genotypes__,
+ "phenotype": __search_phenotypes__
+ }
+ groups = oauth2_get("auth/group/list").either(
+ lambda err: {"groups_error": process_error(err)},
+ lambda grps: {"groups": grps})
+ query = request.args.get("query", "")
+ return search_fns[dataset_type](
+ query, templates[dataset_type], **groups, species_name=species_name,
+ dataset_type=dataset_type)
+
+@data.route("/list", methods=["GET", "POST"])
+def list_data():
+ """List ungrouped data."""
+ def __render__(**kwargs):
+ roles = kwargs.get("roles", [])
+ user_privileges = tuple(
+ privilege["privilege_id"] for role in roles
+ for privilege in role["privileges"])
+ return render_ui(
+ "oauth2/data-list.html",
+ groups=kwargs.get("groups", []),
+ data_items=kwargs.get("data_items", []),
+ user_privileges=user_privileges,
+ **{key:val for key,val in kwargs.items()
+ if key not in ("groups", "data_items", "user_privileges")})
+
+ groups = oauth2_get("auth/group/list").either(
+ lambda err: {"groups_error": process_error(err)},
+ lambda grp: {"groups": grp})
+ roles = oauth2_get("auth/system/roles").either(
+ lambda err: {"roles_error": process_error(err)},
+ lambda roles: {"roles": roles})
+ species = oauth2_get("auth/data/species").either(
+ lambda err: {"species_error": process_error(err)},
+ lambda species: {"species": species})
+
+ if request.method == "GET":
+ return __render__(**{**groups, **roles, **species})
+
+ species_name = request.form["species_name"]
+ dataset_type = request.form["dataset_type"]
+ if dataset_type not in ("mrna", "genotype", "phenotype"):
+ flash("InvalidDatasetType: An invalid dataset type was provided",
+ "alert-danger")
+ return __render__(**{**groups, **roles, **species})
+
+ return redirect(url_for(
+ "oauth2.data.list_data_by_species_and_dataset",
+ species_name=species_name, dataset_type=dataset_type))
+
+@data.route("/link", methods=["POST"])
+def link_data():
+ """Link the selected data to a specific group."""
+ def __error__(err, form_data):
+ error = process_error(err)
+ flash(f"{error['error']}: {error['error_description']}", "alert-danger")
+ return redirect(url_for("oauth2.data.list_data", **form_data), code=307)
+ def __success__(success, form_data):
+ flash("Data successfully linked!", "alert-success")
+ return redirect(url_for("oauth2.data.list_data", **form_data), code=307)
+
+ form = request.form
+ try:
+ keys = ("dataset_type", "group_id")
+ assert all(item in form for item in keys)
+ assert all(bool(form[item]) for item in keys)
+ state_data = {
+ "dataset_type": form["dataset_type"],
+ "offset": form.get("offset", 0)}
+ dataset_ids = form.getlist("dataset_ids")
+ if len(dataset_ids) == 0:
+ flash("You must select at least one item to link", "alert-danger")
+ return redirect(url_for(
+ "oauth2.data.list_data", **state_data))
+ return oauth2_post(
+ "auth/group/data/link",
+ data={
+ "dataset_type": form["dataset_type"],
+ "dataset_ids": dataset_ids,
+ "group_id": form["group_id"]
+ }).either(lambda err: __error__(err, state_data),
+ lambda success: __success__(success, state_data))
+ except AssertionError as aserr:
+ flash("You must provide all the expected data.", "alert-danger")
+ return redirect(url_for("oauth2.data.list_data"))
+
+@data.route("/link/genotype", methods=["POST"])
+def link_genotype_data():
+ """Link genotype data to a group."""
+ form = request.form
+ link_source_url = redirect(url_for("oauth2.data.list_data"))
+ if bool(form.get("species_name")):
+ link_source_url = redirect(url_for(
+ "oauth2.data.list_data_by_species_and_dataset",
+ species_name=form["species_name"], dataset_type="genotype"))
+
+ def __link_error__(err):
+ flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+ return link_source_url
+
+ def __link_success__(success):
+ flash(success["description"], "alert-success")
+ return link_source_url
+
+ return oauth2_post("auth/data/link/genotype", json={
+ "species_name": form.get("species_name"),
+ "group_id": form.get("group_id"),
+ "selected": tuple(json.loads(dataset) for dataset
+ in form.getlist("selected"))
+ }).either(lambda err: __link_error__(process_error(err)), __link_success__)
+
+
+@data.route("/link/mrna", methods=["POST"])
+def link_mrna_data():
+ """Link mrna data to a group."""
+ form = request.form
+ link_source_url = redirect(url_for("oauth2.data.list_data"))
+ if bool(form.get("species_name")):
+ link_source_url = redirect(url_for(
+ "oauth2.data.list_data_by_species_and_dataset",
+ species_name=form["species_name"], dataset_type="mrna"))
+
+ def __link_error__(err):
+ error = process_error(err)
+ flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+ return link_source_url
+
+ def __link_success__(success):
+ flash(success["description"], "alert-success")
+ return link_source_url
+
+ return oauth2_post("auth/data/link/mrna", json={
+ "species_name": form.get("species_name"),
+ "group_id": form.get("group_id"),
+ "selected": tuple(json.loads(dataset) for dataset
+ in form.getlist("selected"))
+ }).either(lambda err: __link_error__(process_error(err)), __link_success__)
+
+@data.route("/link/phenotype", methods=["POST"])
+def link_phenotype_data():
+ """Link phenotype data to a group."""
+ form = request.form
+ link_source_url = redirect(url_for("oauth2.data.list_data"))
+ if bool(form.get("species_name")):
+ link_source_url = redirect(url_for(
+ "oauth2.data.list_data_by_species_and_dataset",
+ species_name=form["species_name"], dataset_type="phenotype"))
+
+ def __link_error__(err):
+ error = process_error(err)
+ flash(f"{error['error']}: {error['error_description']}", "alert-danger")
+ return link_source_url
+
+ def __link_success__(success):
+ flash(success["description"], "alert-success")
+ return link_source_url
+
+ return oauth2_post("auth/data/link/phenotype", json={
+ "species_name": form.get("species_name"),
+ "group_id": form.get("group_id"),
+ "selected": tuple(
+ json.loads(trait) for trait in form.getlist("selected"))}).either(
+ __link_error__, __link_success__)
diff --git a/gn2/wqflask/oauth2/groups.py b/gn2/wqflask/oauth2/groups.py
new file mode 100644
index 00000000..fd5ab7eb
--- /dev/null
+++ b/gn2/wqflask/oauth2/groups.py
@@ -0,0 +1,210 @@
+import uuid
+import datetime
+from functools import partial
+
+from flask import (
+ flash, session, request, url_for, redirect, Response, Blueprint)
+
+from .ui import render_ui
+from .checks import require_oauth2
+from .client import oauth2_get, oauth2_post
+from .request_utils import (
+ user_details, handle_error, process_error, handle_success,
+ raise_unimplemented)
+
+groups = Blueprint("group", __name__)
+
+@groups.route("/", methods=["GET"])
+def user_group():
+ """Get the user's group."""
+ def __get_join_requests__(group, users):
+ return oauth2_get("auth/group/requests/join/list").either(
+ lambda error: render_ui(
+ "oauth2/group.html", group=group, users=users,
+ group_join_requests_error=process_error(error)),
+ lambda gjr: render_ui(
+ "oauth2/group.html", group=group, users=users,
+ group_join_requests=gjr))
+ def __success__(group):
+ return oauth2_get(f"auth/group/members/{group['group_id']}").either(
+ lambda error: render_ui(
+ "oauth2/group.html", group=group,
+ user_error=process_error(error)),
+ partial(__get_join_requests__, group))
+
+ def __group_error__(err):
+ return render_ui(
+ "oauth2/group.html", group_error=process_error(err))
+
+ return oauth2_get("auth/user/group").either(
+ __group_error__, __success__)
+
+@groups.route("/create", methods=["POST"])
+@require_oauth2
+def create_group():
+ def __setup_group__(response):
+ session["user_details"]["group"] = response
+
+ resp = oauth2_post("auth/group/create", data=dict(request.form))
+ return resp.either(
+ handle_error("oauth2.group.join_or_create"),
+ handle_success(
+ "Created group", "oauth2.user.user_profile",
+ response_handlers=[__setup_group__]))
+
+@groups.route("/join-or-create", methods=["GET"])
+@require_oauth2
+def join_or_create():
+ usr_dets = user_details()
+ if bool(usr_dets["group"]):
+ flash("You are already a member of a group.", "alert-info")
+ return redirect(url_for("oauth2.user.user_profile"))
+ def __group_error__(err):
+ return render_ui(
+ "oauth2/group_join_or_create.html", groups=[],
+ groups_error=process_error(err))
+ def __group_success__(groups):
+ return oauth2_get("auth/user/group/join-request").either(
+ __gjr_error__, partial(__gjr_success__, groups=groups))
+ def __gjr_error__(err):
+ return render_ui(
+ "oauth2/group_join_or_create.html", groups=[],
+ gjr_error=process_error(err))
+ def __gjr_success__(gjr, groups):
+ return render_ui(
+ "oauth2/group_join_or_create.html", groups=groups,
+ group_join_request=gjr)
+ return oauth2_get("auth/group/list").either(
+ __group_error__, __group_success__)
+
+@groups.route("/delete/<uuid:group_id>", methods=["GET", "POST"])
+@require_oauth2
+def delete_group(group_id):
+ """Delete the user's group."""
+ return "WOULD DELETE GROUP."
+
+@groups.route("/edit/<uuid:group_id>", methods=["GET", "POST"])
+@require_oauth2
+def edit_group(group_id):
+ """Edit the user's group."""
+ return "WOULD EDIT GROUP."
+
+@groups.route("/list-join-requests", methods=["GET"])
+@require_oauth2
+def list_join_requests() -> Response:
+ def __ts_to_dt_str__(timestamp):
+ return datetime.datetime.fromtimestamp(timestamp).isoformat()
+ def __fail__(error):
+ return render_ui(
+ "oauth2/join-requests.html", error=process_error(error),
+ requests=[])
+ def __success__(requests):
+ return render_ui(
+ "oauth2/join-requests.html", error=False, requests=requests,
+ datetime_string=__ts_to_dt_str__)
+ return oauth2_get("auth/group/requests/join/list").either(
+ __fail__, __success__)
+
+@groups.route("/accept-join-requests", methods=["POST"])
+@require_oauth2
+def accept_join_request():
+ def __fail__(error):
+ err=process_error()
+ flash("{}", "alert-danger")
+ return redirect(url_for("oauth2.group.list_join_requests"))
+ def __success__(requests):
+ flash("Request was accepted successfully.", "alert-success")
+ return redirect(url_for("oauth2.group.list_join_requests"))
+ return oauth2_post(
+ "auth/group/requests/join/accept",
+ data=request.form).either(
+ handle_error("oauth2.group.list_join_requests"),
+ __success__)
+
+@groups.route("/reject-join-requests", methods=["POST"])
+@require_oauth2
+def reject_join_request():
+ def __fail__(error):
+ err=process_error()
+ flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+ return redirect(url_for("oauth2.group.list_join_requests"))
+ def __success__(requests):
+ flash("Request was rejected successfully.", "alert-success")
+ return redirect(url_for("oauth2.group.list_join_requests"))
+ return oauth2_post(
+ "auth/group/requests/join/reject",
+ data=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:
+ """Add/delete a privilege to/from a role depending on `direction`."""
+ assert direction in ("ADD", "DELETE")
+ def __render__():
+ return redirect(url_for(
+ "oauth2.group.group_role", group_role_id=group_role_id))
+
+ def __error__(error):
+ err = process_error(error)
+ flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+ return __render__()
+
+ def __success__(success):
+ flash(success["description"], "alert-success")
+ return __render__()
+ try:
+ form = request.form
+ privilege_id = form.get("privilege_id")
+ assert bool(privilege_id), "Privilege to add must be provided"
+ uris = {
+ "ADD": f"auth/group/role/{group_role_id}/privilege/add",
+ "DELETE": f"auth/group/role/{group_role_id}/privilege/delete"
+ }
+ return oauth2_post(
+ uris[direction],
+ data={
+ "group_role_id": group_role_id,
+ "privilege_id": privilege_id
+ }).either(__error__, __success__)
+ except AssertionError as aerr:
+ flash(aerr.args[0], "alert-danger")
+ return redirect(url_for(
+ "oauth2.group.group_role", group_role_id=group_role_id))
+
+@groups.route("/role/<uuid:group_role_id>/privilege/add", methods=["POST"])
+@require_oauth2
+def add_privilege_to_role(group_role_id: uuid.UUID):
+ """Add a privilege to a group role."""
+ return add_delete_privilege_to_role(group_role_id, "ADD")
+
+@groups.route("/role/<uuid:group_role_id>/privilege/delete", methods=["POST"])
+@require_oauth2
+def delete_privilege_from_role(group_role_id: uuid.UUID):
+ """Delete a privilege from a group role."""
+ return add_delete_privilege_to_role(group_role_id, "DELETE")
diff --git a/gn2/wqflask/oauth2/request_utils.py b/gn2/wqflask/oauth2/request_utils.py
new file mode 100644
index 00000000..ade0923b
--- /dev/null
+++ b/gn2/wqflask/oauth2/request_utils.py
@@ -0,0 +1,99 @@
+"""General request utilities"""
+from typing import Optional, Callable
+from urllib.parse import urljoin, urlparse
+
+import simplejson
+from flask import (
+ flash, request, url_for, redirect, Response, render_template,
+ current_app as app)
+
+from .client import SCOPE, oauth2_get
+
+def authserver_authorise_uri():
+ from gn2.utility.tools import AUTH_SERVER_URL, OAUTH2_CLIENT_ID
+ req_baseurl = urlparse(request.base_url, scheme=request.scheme)
+ host_uri = f"{req_baseurl.scheme}://{req_baseurl.netloc}/"
+ return urljoin(
+ AUTH_SERVER_URL,
+ "auth/authorise?response_type=code"
+ f"&client_id={OAUTH2_CLIENT_ID}"
+ f"&redirect_uri={urljoin(host_uri, 'oauth2/code')}")
+
+def raise_unimplemented():
+ raise Exception("NOT IMPLEMENTED")
+
+def user_details():
+ return oauth2_get("auth/user/").either(
+ lambda err: {},
+ lambda usr_dets: usr_dets)
+
+def process_error(error: Response,
+ message: str=("Requested endpoint was not found on the API "
+ "server.")
+ ) -> dict:
+ if error.status_code in range(400, 500):
+ try:
+ err = error.json()
+ msg = err.get("error_description", f"{error.reason}")
+ except simplejson.errors.JSONDecodeError as _jde:
+ msg = message
+ return {
+ "error": error.reason,
+ "error_message": msg,
+ "error_description": msg,
+ "status_code": error.status_code
+ }
+ return {**error.json(), "status_code": error.status_code}
+
+def request_error(response):
+ app.logger.error(f"{response}: {response.url} [{response.status_code}]")
+ return render_template("oauth2/request_error.html", response=response)
+
+def handle_error(redirect_uri: Optional[str] = None, **kwargs):
+ def __handler__(error):
+ error_json = process_error(error)# error.json()
+ msg = error_json.get(
+ "error_message", error_json.get(
+ "error_description", "undefined error"))
+ flash(f"{error_json['error']}: {msg}.",
+ "alert-danger")
+ if "response_handlers" in kwargs:
+ for handler in kwargs["response_handlers"]:
+ handler(response)
+ if redirect:
+ return redirect(url_for(redirect_uri, **kwargs))
+
+ return __handler__
+
+def handle_success(
+ success_msg: str, redirect_uri: Optional[str] = None, **kwargs):
+ def __handler__(response):
+ flash(f"Success: {success_msg}.", "alert-success")
+ if "response_handlers" in kwargs:
+ for handler in kwargs["response_handlers"]:
+ handler(response)
+ if redirect:
+ return redirect(url_for(redirect_uri, **kwargs))
+
+ return __handler__
+
+def flash_error(error):
+ flash(f"{error['error']}: {error['error_description']}", "alert-danger")
+
+def flash_success(success):
+ flash(f"{success['description']}", "alert-success")
+
+def with_flash_error(response) -> Callable:
+ def __err__(err) -> Response:
+ error = process_error(err)
+ flash(f"{error['status_code']} {error['error']}: "
+ f"{error['error_description']}",
+ "alert-danger")
+ return response
+ return __err__
+
+def with_flash_success(response) -> Callable:
+ def __succ__(msg) -> Response:
+ flash(f"Success: {msg['message']}", "alert-success")
+ return response
+ return __succ__
diff --git a/gn2/wqflask/oauth2/resources.py b/gn2/wqflask/oauth2/resources.py
new file mode 100644
index 00000000..7d20b859
--- /dev/null
+++ b/gn2/wqflask/oauth2/resources.py
@@ -0,0 +1,294 @@
+import uuid
+
+from flask import (
+ flash, request, jsonify, url_for, redirect, Response, Blueprint)
+
+from . import client
+from .ui import 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)
+
+resources = Blueprint("resource", __name__)
+
+@resources.route("/", methods=["GET"])
+@require_oauth2
+def user_resources():
+ """List the resources the user has access to."""
+ def __success__(resources):
+ return render_ui("oauth2/resources.html", resources=resources)
+
+ return oauth2_get("auth/user/resources").either(
+ request_error, __success__)
+
+@resources.route("/create", methods=["GET", "POST"])
+@require_oauth2
+def create_resource():
+ """Create a new resource."""
+ def __render_template__(categories=[], error=None):
+ return render_ui(
+ "oauth2/create-resource.html",
+ resource_categories=categories,
+ resource_category_error=error,
+ resource_name=request.args.get("resource_name"),
+ resource_category=request.args.get("resource_category"))
+
+ if request.method == "GET":
+ return oauth2_get("auth/resource/categories").either(
+ lambda error: __render_template__(error=process_error(
+ error, "Could not retrieve resource categories")),
+ lambda cats: __render_template__(categories=cats))
+
+ def __perr__(error):
+ err = process_error(error)
+ flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+ return redirect(url_for(
+ "oauth2.resource.create_resource",
+ resource_name=request.form.get("resource_name"),
+ resource_category=request.form.get("resource_category")))
+ def __psuc__(succ):
+ flash("Resource created successfully", "alert-success")
+ return redirect(url_for("oauth2.resource.user_resources"))
+ return oauth2_post(
+ "auth/resource/create", data=request.form).either(
+ __perr__, __psuc__)
+
+def __compute_page__(submit, current_page):
+ if submit == "next":
+ return current_page + 1
+ return (current_page - 1) or 1
+
+@resources.route("/view/<uuid:resource_id>", methods=["GET"])
+@require_oauth2
+def view_resource(resource_id: uuid.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,
+ 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,
+ page=page, count_per_page=count_per_page)
+
+ def __group_roles_success__(
+ resource, unlinked_data, users_n_roles, this_user, group_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,
+ users_error=process_error(err)),
+ lambda users: __users_success__(
+ resource, unlinked_data, users_n_roles, this_user, group_roles,
+ users))
+
+ def __this_user_success__(resource, unlinked_data, users_n_roles, this_user):
+ return oauth2_get("auth/group/roles").either(
+ lambda err: render_ui(
+ "oauth2/view-resources.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))
+
+ 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",
+ this_user_error=process_error(err)),
+ lambda usr_dets: __this_user_success__(
+ resource, unlinked_data, users_n_roles, usr_dets))
+
+ def __unlinked_success__(resource, unlinked_data):
+ return oauth2_get(f"auth/resource/{resource_id}/user/list").either(
+ lambda err: render_ui(
+ "oauth2/view-resource.html",
+ resource=resource,
+ unlinked_data=unlinked_data,
+ users_n_roles_error=process_error(err),
+ page=page,
+ count_per_page=count_per_page),
+ lambda users_n_roles: __users_n_roles_success__(
+ resource, unlinked_data, users_n_roles))
+
+ def __resource_success__(resource):
+ 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)),
+ lambda unlinked: __unlinked_success__(resource, unlinked))
+
+ def __fetch_resource_data__(resource):
+ """Fetch the resource's data."""
+ return client.get(
+ f"auth/resource/view/{resource['resource_id']}/data?page={page}"
+ f"&count_per_page={count_per_page}").either(
+ lambda err: {
+ **resource, "resource_data_error": process_error(err)
+ },
+ lambda resdata: {**resource, "resource_data": resdata})
+
+ return oauth2_get(f"auth/resource/view/{resource_id}").map(
+ __fetch_resource_data__).either(
+ lambda err: render_ui(
+ "oauth2/view-resource.html",
+ resource=None, resource_error=process_error(err)),
+ __resource_success__)
+
+@resources.route("/data/link", methods=["POST"])
+@require_oauth2
+def link_data_to_resource():
+ """Link group data to a resource"""
+ form = request.form
+ try:
+ assert "resource_id" in form, "Resource ID not provided."
+ assert "data_link_id" in form, "Data Link ID not provided."
+ assert "dataset_type" in form, "Dataset type not specified"
+ assert form["dataset_type"].lower() in (
+ "mrna", "genotype", "phenotype"), "Invalid dataset type provided."
+ resource_id = form["resource_id"]
+
+ def __error__(error):
+ err = process_error(error)
+ flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=resource_id))
+
+ def __success__(success):
+ 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(
+ __error__,
+ __success__)
+ except AssertionError as aserr:
+ flash(aserr.args[0], "alert-danger")
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=form["resource_id"]))
+
+@resources.route("/data/unlink", methods=["POST"])
+@require_oauth2
+def unlink_data_from_resource():
+ """Unlink group data from a resource"""
+ form = request.form
+ try:
+ assert "resource_id" in form, "Resource ID not provided."
+ assert "data_link_id" in form, "Data Link ID not provided."
+ resource_id = form["resource_id"]
+
+ def __error__(error):
+ err = process_error(error)
+ flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=resource_id))
+
+ def __success__(success):
+ flash(f"Data unlinked from resource successfully", "alert-success")
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=resource_id))
+ return oauth2_post(
+ "auth/resource/data/unlink", data=dict(form)).either(
+ __error__, __success__)
+ except AssertionError as aserr:
+ flash(aserr.args[0], "alert-danger")
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=form["resource_id"]))
+
+@resources.route("<uuid:resource_id>/user/assign", methods=["POST"])
+@require_oauth2
+def assign_role(resource_id: uuid.UUID) -> Response:
+ form = request.form
+ group_role_id = form.get("group_role_id", "")
+ user_email = form.get("user_email", "")
+ try:
+ assert bool(group_role_id), "The role must be provided."
+ assert bool(user_email), "The user email must be provided."
+
+ def __assign_error__(error):
+ err = process_error(error)
+ flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=resource_id))
+
+ def __assign_success__(success):
+ flash(success["description"], "alert-success")
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=resource_id))
+
+ return oauth2_post(
+ f"auth/resource/{resource_id}/user/assign",
+ data={
+ "group_role_id": group_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))
+
+@resources.route("<uuid:resource_id>/user/unassign", methods=["POST"])
+@require_oauth2
+def unassign_role(resource_id: uuid.UUID) -> Response:
+ form = request.form
+ group_role_id = form.get("group_role_id", "")
+ user_id = form.get("user_id", "")
+ try:
+ assert bool(group_role_id), "The role must be provided."
+ assert bool(user_id), "The user id must be provided."
+
+ def __unassign_error__(error):
+ err = process_error(error)
+ flash(f"{err['error']}: {err['error_description']}", "alert-danger")
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=resource_id))
+
+ def __unassign_success__(success):
+ flash(success["description"], "alert-success")
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=resource_id))
+
+ return oauth2_post(
+ f"auth/resource/{resource_id}/user/unassign",
+ data={
+ "group_role_id": group_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))
+
+@resources.route("/toggle/<uuid:resource_id>", methods=["POST"])
+@require_oauth2
+def toggle_public(resource_id: uuid.UUID):
+ """Toggle the given resource's public status."""
+ def __handle_error__(err):
+ flash_error(process_error(err))
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=resource_id))
+
+ def __handle_success__(success):
+ flash_success(success)
+ return redirect(url_for(
+ "oauth2.resource.view_resource", resource_id=resource_id))
+
+ return oauth2_post(
+ f"auth/resource/{resource_id}/toggle-public", data={}).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):
+ """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):
+ """Delete the given resource."""
+ return "WOULD DELETE THE GIVEN RESOURCE"
diff --git a/gn2/wqflask/oauth2/roles.py b/gn2/wqflask/oauth2/roles.py
new file mode 100644
index 00000000..2fe35f9b
--- /dev/null
+++ b/gn2/wqflask/oauth2/roles.py
@@ -0,0 +1,99 @@
+"""Handle role endpoints"""
+import uuid
+
+from flask import flash, request, url_for, redirect, Blueprint
+
+from .ui import render_ui
+from .checks import require_oauth2
+from .client import oauth2_get, oauth2_post
+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):
+ def __success__(the_role):
+ return render_ui("oauth2/role.html",
+ role=the_role[0],
+ resource_id=uuid.UUID(the_role[1]))
+
+ 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/routes.py b/gn2/wqflask/oauth2/routes.py
new file mode 100644
index 00000000..4c4b877b
--- /dev/null
+++ b/gn2/wqflask/oauth2/routes.py
@@ -0,0 +1,18 @@
+"""Routes for the OAuth2 auth system in GN3"""
+from flask import Blueprint
+
+from .data import data
+from .users import users
+from .roles import roles
+from .groups import groups
+from .toplevel import toplevel
+from .resources import resources
+
+oauth2 = Blueprint("oauth2", __name__, template_folder="templates/oauth2")
+
+oauth2.register_blueprint(toplevel, url_prefix="/")
+oauth2.register_blueprint(data, url_prefix="/data")
+oauth2.register_blueprint(users, url_prefix="/user")
+oauth2.register_blueprint(roles, url_prefix="/role")
+oauth2.register_blueprint(groups, url_prefix="/group")
+oauth2.register_blueprint(resources, url_prefix="/resource")
diff --git a/gn2/wqflask/oauth2/session.py b/gn2/wqflask/oauth2/session.py
new file mode 100644
index 00000000..2ef534e2
--- /dev/null
+++ b/gn2/wqflask/oauth2/session.py
@@ -0,0 +1,111 @@
+"""Deal with user sessions"""
+from uuid import UUID, uuid4
+from datetime import datetime
+from typing import Any, Optional, TypedDict
+
+from flask import request, session
+from pymonad.either import Left, Right, Either
+
+class UserDetails(TypedDict):
+ """Session information relating specifically to the user."""
+ user_id: UUID
+ name: str
+ email: str
+ token: Either
+ logged_in: bool
+
+class SessionInfo(TypedDict):
+ """All Session information we save."""
+ session_id: UUID
+ user: UserDetails
+ anon_id: UUID
+ user_agent: str
+ ip_addr: str
+ masquerade: Optional[UserDetails]
+
+__SESSION_KEY__ = "GN::2::session_info" # Do not use this outside this module!!
+
+def clear_session_info():
+ """Clears the session."""
+ session.pop(__SESSION_KEY__)
+
+def save_session_info(sess_info: SessionInfo) -> SessionInfo:
+ """Save `session_info`."""
+ # TODO: if it is an existing session, verify that certain important security
+ # bits have not changed before saving.
+ # old_session_info = session.get(__SESSION_KEY__)
+ # if bool(old_session_info):
+ # if old_session_info["user_agent"] == request.headers.get("User-Agent"):
+ # session[__SESSION_KEY__] = sess_info
+ # return sess_info
+ # # request session verification
+ # return verify_session(sess_info)
+ # New session
+ session[__SESSION_KEY__] = sess_info
+ return sess_info
+
+def session_info() -> SessionInfo:
+ """Retrieve the session information"""
+ anon_id = uuid4()
+ return save_session_info(
+ session.get(__SESSION_KEY__, {
+ "session_id": uuid4(),
+ "user": {
+ "user_id": anon_id,
+ "name": "Anonymous User",
+ "email": "anon@ymous.user",
+ "token": Left("INVALID-TOKEN"),
+ "logged_in": False
+ },
+ "anon_id": anon_id,
+ "user_agent": request.headers.get("User-Agent"),
+ "ip_addr": request.environ.get("HTTP_X_FORWARDED_FOR",
+ request.remote_addr),
+ "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."""
+ info = session_info()
+ return save_session_info({
+ **info, "user": {**info["user"], "token": Right(token)}})
+
+def set_user_details(userdets: UserDetails) -> SessionInfo:
+ """Set the user details information"""
+ return save_session_info({**session_info(), "user": userdets})
+
+def user_token() -> Either:
+ """Retrieve the user token."""
+ return session_info()["user"]["token"]
+
+def set_masquerading(masq_info):
+ """Save the masquerading user information."""
+ orig_user = session_info()["user"]
+ return save_session_info({
+ **session_info(),
+ "user": {
+ "user_id": UUID(masq_info["masquerade_as"]["user"]["user_id"]),
+ "name": masq_info["masquerade_as"]["user"]["name"],
+ "email": masq_info["masquerade_as"]["user"]["email"],
+ "token": Right(masq_info["masquerade_as"]["token"]),
+ "logged_in": True
+ },
+ "masquerading": orig_user
+ })
+
+def unset_masquerading():
+ """Restore the original session."""
+ the_session = session_info()
+ return save_session_info({
+ **the_session,
+ "user": the_session["masquerading"],
+ "masquerading": None
+ })
diff --git a/gn2/wqflask/oauth2/toplevel.py b/gn2/wqflask/oauth2/toplevel.py
new file mode 100644
index 00000000..65f60067
--- /dev/null
+++ b/gn2/wqflask/oauth2/toplevel.py
@@ -0,0 +1,57 @@
+"""Authentication endpoints."""
+from uuid import UUID
+from urllib.parse import urljoin, urlparse, urlunparse
+from flask import (
+ flash, request, Blueprint, url_for, redirect, render_template,
+ current_app as app)
+
+from . import session
+from .client import SCOPE, no_token_post
+from .checks import require_oauth2, user_logged_in
+from .request_utils import user_details, process_error
+
+toplevel = Blueprint("toplevel", __name__)
+
+@toplevel.route("/register-client", methods=["GET", "POST"])
+@require_oauth2
+def register_client():
+ """Register an OAuth2 client."""
+ return "USER IS LOGGED IN AND SUCCESSFULLY ACCESSED THIS ENDPOINT!"
+
+@toplevel.route("/code", methods=["GET"])
+def authorisation_code():
+ """Use authorisation code to get token."""
+ def __error__(error):
+ flash(f"{error['error']}: {error['error_description']}",
+ "alert-danger")
+ return redirect("/")
+
+ def __success__(token):
+ session.set_user_token(token)
+ udets = user_details()
+ session.set_user_details({
+ "user_id": UUID(udets["user_id"]),
+ "name": udets["name"],
+ "email": udets["email"],
+ "token": session.user_token(),
+ "logged_in": True
+ })
+ return redirect("/")
+
+ code = request.args.get("code", "")
+ if bool(code):
+ base_url = urlparse(request.base_url, scheme=request.scheme)
+ request_data = {
+ "grant_type": "authorization_code",
+ "code": code,
+ "scope": SCOPE,
+ "redirect_uri": urljoin(
+ urlunparse(base_url),
+ url_for("oauth2.toplevel.authorisation_code")),
+ "client_id": app.config["OAUTH2_CLIENT_ID"]
+ }
+ return no_token_post(
+ "auth/token", data=request_data).either(
+ lambda err: __error__(process_error(err)), __success__)
+ flash("AuthorisationError: No code was provided.", "alert-danger")
+ return redirect("/")
diff --git a/gn2/wqflask/oauth2/ui.py b/gn2/wqflask/oauth2/ui.py
new file mode 100644
index 00000000..b706cdd9
--- /dev/null
+++ b/gn2/wqflask/oauth2/ui.py
@@ -0,0 +1,23 @@
+"""UI utilities"""
+from flask import session, render_template
+
+from .client import oauth2_get
+from .checks 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())
+ user_privileges = tuple(
+ privilege["privilege_id"] for role in roles
+ for privilege in role["privileges"])
+ kwargs = {
+ **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
new file mode 100644
index 00000000..12d07d0c
--- /dev/null
+++ b/gn2/wqflask/oauth2/users.py
@@ -0,0 +1,190 @@
+import requests
+from uuid import UUID
+from urllib.parse import urljoin
+
+from authlib.integrations.base_client.errors import OAuthError
+from flask import (
+ flash, request, url_for, redirect, Response, Blueprint,
+ current_app as app)
+
+from . import client
+from . import session
+from .ui import render_ui
+from .checks import require_oauth2, user_logged_in
+from .client import oauth2_get, oauth2_post, oauth2_client
+from .request_utils import (
+ user_details, request_error, process_error, with_flash_error)
+
+users = Blueprint("user", __name__)
+
+@users.route("/profile", methods=["GET"])
+@require_oauth2
+def user_profile():
+ __id__ = lambda the_val: the_val
+ usr_dets = user_details()
+ def __render__(usr_dets, roles=[], **kwargs):
+ return render_ui(
+ "oauth2/view-user.html", user_details=usr_dets, roles=roles,
+ user_privileges = tuple(
+ privilege["privilege_id"] for role in roles
+ for privilege in role["privileges"]),
+ **kwargs)
+
+ def __roles_success__(roles):
+ if bool(usr_dets.get("group")):
+ return __render__(usr_dets, roles)
+ return oauth2_get("auth/user/group/join-request").either(
+ lambda err: __render__(
+ user_details, group_join_error=process_error(err)),
+ lambda gjr: __render__(usr_dets, roles=roles, group_join_request=gjr))
+
+ return oauth2_get("auth/system/roles").either(
+ lambda err: __render__(usr_dets, role_error=process_error(err)),
+ __roles_success__)
+
+@users.route("/request-add-to-group", methods=["POST"])
+@require_oauth2
+def request_add_to_group() -> Response:
+ """Request to be added to a group."""
+ form = request.form
+ group_id = form["group"]
+
+ def __error__(error):
+ err = process_error(error)
+ flash(f"{err['error']}: {err['error_message']}", "alert-danger")
+ return redirect(url_for("oauth2.user.user_profile"))
+
+ def __success__(response):
+ flash(f"{response['message']} (Response ID: {response['request_id']})",
+ "alert-success")
+ return redirect(url_for("oauth2.user.user_profile"))
+
+ return oauth2_post(f"auth/group/requests/join/{group_id}",
+ data=form).either(__error__, __success__)
+
+@users.route("/login", methods=["GET", "POST"])
+def login():
+ """Route to allow users to sign up."""
+ from gn2.utility.tools import AUTH_SERVER_URL
+ next_endpoint=request.args.get("next", False)
+
+ if request.method == "POST":
+ form = request.form
+ client = oauth2_client()
+ try:
+ token = client.fetch_token(
+ urljoin(AUTH_SERVER_URL, "auth/token"),
+ username=form.get("email_address"),
+ password=form.get("password"),
+ grant_type="password")
+ session.set_user_token(token)
+ udets = user_details()
+ session.set_user_details({
+ "user_id": UUID(udets["user_id"]),
+ "name": udets["name"],
+ "email": udets["email"],
+ "token": session.user_token(),
+ "logged_in": True
+ })
+ except OAuthError as _oaerr:
+ flash(_oaerr.args[0], "alert-danger")
+ return render_ui(
+ "oauth2/login.html", next_endpoint=next_endpoint,
+ email=form.get("email_address"))
+
+ if user_logged_in():
+ if next_endpoint:
+ return redirect(url_for(next_endpoint))
+ return redirect("/")
+
+ return render_ui("oauth2/login.html", next_endpoint=next_endpoint)
+
+@users.route("/logout", methods=["GET", "POST"])
+def logout():
+ from gn2.utility.tools import AUTH_SERVER_URL
+ if user_logged_in():
+ resp = oauth2_client().revoke_token(
+ urljoin(AUTH_SERVER_URL, "auth/revoke"))
+ the_session = session.session_info()
+ if not bool(the_session["masquerading"]):
+ # Normal session - clear and go back.
+ session.clear_session_info()
+ flash("Successfully logged out.", "alert-success")
+ return redirect("/")
+ # Restore masquerading session
+ session.unset_masquerading()
+ flash(
+ "Successfully logged out as user "
+ f"{the_session['user']['name']} ({the_session['user']['email']}) "
+ "and restored session for user "
+ f"{the_session['masquerading']['name']} "
+ f"({the_session['masquerading']['email']})",
+ "alert-success")
+ return redirect("/")
+
+@users.route("/register", methods=["GET", "POST"])
+def register_user():
+ from gn2.utility.tools import AUTH_SERVER_URL
+ if user_logged_in():
+ next_endpoint=request.args.get("next", "/")
+ flash(("You cannot register a new user while logged in. "
+ "Please logout to register a new user."),
+ "alert-danger")
+ return redirect(next_endpoint)
+
+ if request.method == "GET":
+ return render_ui("oauth2/register_user.html")
+
+ form = request.form
+ response = requests.post(
+ urljoin(AUTH_SERVER_URL, "auth/user/register"),
+ data = {
+ "user_name": form.get("user_name"),
+ "email": form.get("email_address"),
+ "password": form.get("password"),
+ "confirm_password": form.get("confirm_password")})
+ results = response.json()
+ if "error" in results:
+ error_messages = tuple(
+ f"{results['error']}: {msg.strip()}"
+ for msg in results.get("error_description").split("::"))
+ for message in error_messages:
+ flash(message, "alert-danger")
+ return redirect(url_for("oauth2.user.register_user"))
+
+ flash("Registration successful! Please login to continue.", "alert-success")
+ return redirect(url_for("oauth2.user.login"))
+
+@users.route("/masquerade", methods=["GET", "POST"])
+def masquerade():
+ """Masquerade as a particular user."""
+ if request.method == "GET":
+ this_user = session.session_info()["user"]
+ return client.get("auth/user/list").either(
+ lambda err: render_ui(
+ "oauth2/masquerade.html", users_error=process_error(err)),
+ lambda usrs: render_ui(
+ "oauth2/masquerade.html", users=tuple(
+ usr for usr in usrs
+ if UUID(usr["user_id"]) != this_user["user_id"])))
+
+ def __masq_success__(masq_details):
+ session.set_masquerading(masq_details)
+ flash(
+ f"User {masq_details['original']['user']['name']} "
+ f"({masq_details['original']['user']['email']}) is now "
+ "successfully masquerading as the user "
+ f"User {masq_details['masquerade_as']['user']['name']} "
+ f"({masq_details['masquerade_as']['user']['email']}) is now ",
+ "alert-success")
+ return redirect("/")
+ form = request.form
+ masquerade_as = form.get("masquerade_as").strip()
+ if not(bool(masquerade_as)):
+ flash("You must provide a user to masquerade as.", "alert-danger")
+ return redirect(url_for("oauth2.user.masquerade"))
+ return client.post(
+ "auth/user/masquerade/",
+ json={"masquerade_as": request.form.get("masquerade_as")}).either(
+ with_flash_error(redirect(url_for("oauth2.user.masquerade"))),
+ __masq_success__)