From 204a308be0f741726b9a620d88fbc22b22124c81 Mon Sep 17 00:00:00 2001 From: Arun Isaac Date: Fri, 29 Dec 2023 18:55:37 +0000 Subject: 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. --- gn2/wqflask/oauth2/__init__.py | 0 gn2/wqflask/oauth2/checks.py | 49 ++++++ gn2/wqflask/oauth2/client.py | 124 ++++++++++++++ gn2/wqflask/oauth2/collections.py | 16 ++ gn2/wqflask/oauth2/data.py | 319 ++++++++++++++++++++++++++++++++++++ gn2/wqflask/oauth2/groups.py | 210 ++++++++++++++++++++++++ gn2/wqflask/oauth2/request_utils.py | 99 +++++++++++ gn2/wqflask/oauth2/resources.py | 294 +++++++++++++++++++++++++++++++++ gn2/wqflask/oauth2/roles.py | 99 +++++++++++ gn2/wqflask/oauth2/routes.py | 18 ++ gn2/wqflask/oauth2/session.py | 111 +++++++++++++ gn2/wqflask/oauth2/toplevel.py | 57 +++++++ gn2/wqflask/oauth2/ui.py | 23 +++ gn2/wqflask/oauth2/users.py | 190 +++++++++++++++++++++ 14 files changed, 1609 insertions(+) create mode 100644 gn2/wqflask/oauth2/__init__.py create mode 100644 gn2/wqflask/oauth2/checks.py create mode 100644 gn2/wqflask/oauth2/client.py create mode 100644 gn2/wqflask/oauth2/collections.py create mode 100644 gn2/wqflask/oauth2/data.py create mode 100644 gn2/wqflask/oauth2/groups.py create mode 100644 gn2/wqflask/oauth2/request_utils.py create mode 100644 gn2/wqflask/oauth2/resources.py create mode 100644 gn2/wqflask/oauth2/roles.py create mode 100644 gn2/wqflask/oauth2/routes.py create mode 100644 gn2/wqflask/oauth2/session.py create mode 100644 gn2/wqflask/oauth2/toplevel.py create mode 100644 gn2/wqflask/oauth2/ui.py create mode 100644 gn2/wqflask/oauth2/users.py (limited to 'gn2/wqflask/oauth2') diff --git a/gn2/wqflask/oauth2/__init__.py b/gn2/wqflask/oauth2/__init__.py new file mode 100644 index 00000000..e69de29b 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("///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/", methods=["GET", "POST"]) +@require_oauth2 +def delete_group(group_id): + """Delete the user's group.""" + return "WOULD DELETE GROUP." + +@groups.route("/edit/", 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/", 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//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//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/", 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("/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("/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/", 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/", 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/", 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/", 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__) -- cgit v1.2.3