diff options
Diffstat (limited to 'gn_auth/auth')
35 files changed, 1226 insertions, 351 deletions
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py index 572324e..200b25d 100644 --- a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py +++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py @@ -20,6 +20,7 @@ def get_token_user_sub(token: OAuth2Token) -> str:# pylint: disable=[unused-argu class IntrospectionEndpoint(_IntrospectionEndpoint): """Introspect token.""" + CLIENT_AUTH_METHODS = ['client_secret_post'] def query_token(self, token_string: str, token_type_hint: str): """Query the token.""" return _query_token(self, token_string, token_type_hint) diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py index 240ca30..80922f1 100644 --- a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py +++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py @@ -12,6 +12,7 @@ from .utilities import query_token as _query_token class RevocationEndpoint(_RevocationEndpoint): """Revoke the tokens""" ENDPOINT_NAME = "revoke" + CLIENT_AUTH_METHODS = ['client_secret_post'] def query_token(self, token_string: str, token_type_hint: str): """Query the token.""" return _query_token(self, token_string, token_type_hint) diff --git a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py index b0f2cc7..1f53186 100644 --- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py +++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py @@ -74,7 +74,7 @@ class JWTBearerGrant(_JWTBearerGrant): def resolve_client_key(self, client, headers, payload): """Resolve client key to decode assertion data.""" - return app.config["SSL_PUBLIC_KEYS"].get(headers["kid"]) + return client.jwks().find_by_kid(headers["kid"]) def authenticate_user(self, subject): diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py new file mode 100644 index 0000000..2606ac6 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py @@ -0,0 +1,15 @@ +"""Implement model for JWTBearerToken""" +import uuid + +from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken + +from gn_auth.auth.db.sqlite3 import with_db_connection +from gn_auth.auth.authentication.users import user_by_id + +class JWTBearerToken(_JWTBearerToken): + """Overrides default JWTBearerToken class.""" + + def __init__(self, payload, header, options=None, params=None): + super().__init__(payload, header, options, params) + self.user = with_db_connection( + lambda conn:user_by_id(conn, uuid.UUID(payload["sub"]))) diff --git a/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py b/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py index 31c9147..46515c8 100644 --- a/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py +++ b/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py @@ -142,7 +142,7 @@ def link_child_token(conn: db.DbConnection, parenttoken: str, childtoken: str): "WHERE token=:parenttoken"), {"parenttoken": parent.token, "childtoken": childtoken}) - def __check_child__(parent): + def __check_child__(parent):#pylint: disable=[unused-variable] with db.cursor(conn) as cursor: cursor.execute( ("SELECT * FROM jwt_refresh_tokens WHERE token=:parenttoken"), @@ -154,15 +154,17 @@ def link_child_token(conn: db.DbConnection, parenttoken: str, childtoken: str): "activity detected.") return Right(parent) - def __revoke_and_raise_error__(_error_msg_): + def __revoke_and_raise_error__(_error_msg_):#pylint: disable=[unused-variable] load_refresh_token(conn, parenttoken).then( lambda _tok: revoke_refresh_token(conn, _tok)) raise InvalidGrantError(_error_msg_) + def __handle_not_found__(_error_msg_): + raise InvalidGrantError(_error_msg_) + load_refresh_token(conn, parenttoken).maybe( - Left("Token not found"), Right).then( - __check_child__).either(__revoke_and_raise_error__, - __link_to_child__) + Left("Token not found"), Right).either( + __handle_not_found__, __link_to_child__) def is_refresh_token_valid(token: JWTRefreshToken, client: OAuth2Client) -> bool: diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py index d31faf6..2c36f45 100644 --- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py +++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py @@ -1,13 +1,14 @@ """OAuth2 Client model.""" import json +import logging import datetime -from pathlib import Path - from uuid import UUID -from dataclasses import dataclass from functools import cached_property -from typing import Sequence, Optional +from dataclasses import asdict, dataclass +from typing import Any, Sequence, Optional +import requests +from requests.exceptions import JSONDecodeError from authlib.jose import KeySet, JsonWebKey from authlib.oauth2.rfc6749 import ClientMixin from pymonad.maybe import Just, Maybe, Nothing @@ -57,16 +58,30 @@ class OAuth2Client(ClientMixin): """ return self.client_metadata.get("client_type", "public") - @cached_property + def jwks(self) -> KeySet: """Return this client's KeySet.""" - def __parse_key__(keypath: Path) -> JsonWebKey: - with open(keypath) as _key:# pylint: disable=[unspecified-encoding] - return JsonWebKey.import_key(_key.read()) + jwksuri = self.client_metadata.get("public-jwks-uri") + if not bool(jwksuri): + logging.debug("No Public JWKs URI set for client!") + return KeySet([]) + try: + ## IMPORTANT: This can cause a deadlock if the client is working in + ## single-threaded mode, i.e. can only serve one request + ## at a time. + return KeySet([JsonWebKey.import_key(key) + for key in requests.get(jwksuri).json()["jwks"]]) + except requests.ConnectionError as _connerr: + logging.debug( + "Could not connect to provided URI: %s", jwksuri, exc_info=True) + except JSONDecodeError as _jsonerr: + logging.debug( + "Could not convert response to JSON", exc_info=True) + except Exception as _exc:# pylint: disable=[broad-except] + logging.debug( + "Error retrieving the JWKs for the client.", exc_info=True) + return KeySet([]) - return KeySet([ - __parse_key__(Path(pth)) - for pth in self.client_metadata.get("public_keys", [])]) def check_endpoint_auth_method(self, method: str, endpoint: str) -> bool: """ @@ -77,12 +92,9 @@ class OAuth2Client(ClientMixin): * client_secret_post: Client uses the HTTP POST parameters * client_secret_basic: Client uses HTTP Basic """ - if endpoint == "token": + if endpoint in ("token", "revoke", "introspection"): return (method in self.token_endpoint_auth_method and method == "client_secret_post") - if endpoint in ("introspection", "revoke"): - return (method in self.token_endpoint_auth_method - and method == "client_secret_basic") return False @cached_property @@ -277,3 +289,22 @@ def delete_client( cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params) cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params) return the_client + + +def update_client_attribute( + client: OAuth2Client, attribute: str, value: Any) -> OAuth2Client: + """Return a new OAuth2Client with the given attribute updated/changed.""" + attrs = { + attr: type(value) + for attr, value in asdict(client).items() + if attr != "client_id" + } + assert ( + attribute in attrs.keys() and isinstance(value, attrs[attribute])), ( + "Invalid attribute/value provided!") + return OAuth2Client( + client_id=client.client_id, + **{ + attr: (value if attr==attribute else getattr(client, attr)) + for attr in attrs + }) diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py index 2405ee2..9c885e2 100644 --- a/gn_auth/auth/authentication/oauth2/resource_server.py +++ b/gn_auth/auth/authentication/oauth2/resource_server.py @@ -1,11 +1,20 @@ """Protect the resources endpoints""" +from datetime import datetime, timezone, timedelta from flask import current_app as app + +from authlib.jose import jwt, KeySet, JoseError from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator +from authlib.oauth2.rfc7523 import ( + JWTBearerTokenValidator as _JWTBearerTokenValidator) from authlib.integrations.flask_oauth2 import ResourceProtector from gn_auth.auth.db import sqlite3 as db -from gn_auth.auth.authentication.oauth2.models.oauth2token import token_by_access_token +from gn_auth.auth.jwks import list_jwks, jwks_directory +from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import ( + JWTBearerToken) +from gn_auth.auth.authentication.oauth2.models.oauth2token import ( + token_by_access_token) class BearerTokenValidator(_BearerTokenValidator): """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`""" @@ -14,4 +23,47 @@ class BearerTokenValidator(_BearerTokenValidator): return token_by_access_token(conn, token_string).maybe(# type: ignore[misc] None, lambda tok: tok) +class JWTBearerTokenValidator(_JWTBearerTokenValidator): + """Validate a token using all the keys""" + token_cls = JWTBearerToken + _local_attributes = ("jwt_refresh_frequency_hours",) + + def __init__(self, public_key, issuer=None, realm=None, **extra_attributes): + """Initialise the validator class.""" + # https://docs.authlib.org/en/latest/jose/jwt.html#use-dynamic-keys + # We can simply use the KeySet rather than a specific key. + super().__init__(public_key, + issuer, + realm, + **{ + key: value for key,value + in extra_attributes.items() + if key not in self._local_attributes + }) + self._last_jwks_update = datetime.now(tz=timezone.utc) + self._refresh_frequency = timedelta(hours=int( + extra_attributes.get("jwt_refresh_frequency_hours", 6))) + + def __refresh_jwks__(self): + now = datetime.now(tz=timezone.utc) + if (now - self._last_jwks_update) >= self._refresh_frequency: + self.public_key = KeySet(list_jwks(jwks_directory(app))) + + def authenticate_token(self, token_string: str): + self.__refresh_jwks__() + for key in self.public_key.keys: + try: + claims = jwt.decode( + token_string, key, + claims_options=self.claims_options, + claims_cls=self.token_cls, + ) + claims.validate() + return claims + except JoseError as error: + app.logger.debug('Authenticate token failed. %r', error) + + return None + + require_oauth = ResourceProtector() diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py index d845c60..a8109b7 100644 --- a/gn_auth/auth/authentication/oauth2/server.py +++ b/gn_auth/auth/authentication/oauth2/server.py @@ -1,15 +1,20 @@ """Initialise the OAuth2 Server""" import uuid -import datetime from typing import Callable +from datetime import datetime from flask import Flask, current_app -from authlib.jose import jwk, jwt -from authlib.oauth2.rfc7523 import JWTBearerTokenValidator +from authlib.jose import jwt, KeySet from authlib.oauth2.rfc6749.errors import InvalidClientError from authlib.integrations.flask_oauth2 import AuthorizationServer +from authlib.oauth2.rfc6749 import OAuth2Request +from authlib.integrations.flask_helpers import create_oauth_request from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.jwks import ( + list_jwks, + jwks_directory, + newest_jwk_with_rotation) from .models.oauth2client import client as fetch_client from .models.oauth2token import OAuth2Token, save_token @@ -27,7 +32,7 @@ from .grants.jwt_bearer_grant import JWTBearerGrant, JWTBearerTokenGenerator from .endpoints.revocation import RevocationEndpoint from .endpoints.introspection import IntrospectionEndpoint -from .resource_server import require_oauth, BearerTokenValidator +from .resource_server import require_oauth, JWTBearerTokenValidator def create_query_client_func() -> Callable: @@ -45,10 +50,14 @@ def create_query_client_func() -> Callable: return __query_client__ -def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable: +def create_save_token_func(token_model: type, app: Flask) -> Callable: """Create the function that saves the token.""" def __save_token__(token, request): - _jwt = jwt.decode(token["access_token"], jwtkey) + _jwt = jwt.decode( + token["access_token"], + newest_jwk_with_rotation( + jwks_directory(app), + int(app.config["JWKS_ROTATION_AGE_DAYS"]))) _token = token_model( token_id=uuid.UUID(_jwt["jti"]), client=request.client, @@ -56,7 +65,7 @@ def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable: **{ "refresh_token": None, "revoked": False, - "issued_at": datetime.datetime.now(), + "issued_at": datetime.now(), **token }) with db.connection(current_app.config["AUTH_DB"]) as conn: @@ -70,8 +79,8 @@ def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable: client=request.client, user=request.user, issued_with=uuid.UUID(_jwt["jti"]), - issued_at=datetime.datetime.fromtimestamp(_jwt["iat"]), - expires=datetime.datetime.fromtimestamp( + issued_at=datetime.fromtimestamp(_jwt["iat"]), + expires=datetime.fromtimestamp( old_refresh_token.then( lambda _tok: _tok.expires.timestamp() ).maybe((int(_jwt["iat"]) + @@ -86,10 +95,8 @@ def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable: return __save_token__ - def make_jwt_token_generator(app): """Make token generator function.""" - _gen = JWTBearerTokenGenerator(app.config["SSL_PRIVATE_KEY"]) def __generator__(# pylint: disable=[too-many-arguments] grant_type, client, @@ -98,19 +105,32 @@ def make_jwt_token_generator(app): expires_in=None,# pylint: disable=[unused-argument] include_refresh_token=True ): - return _gen.__call__( - grant_type, - client, - user, - scope, - JWTBearerTokenGenerator.DEFAULT_EXPIRES_IN, - include_refresh_token) + return JWTBearerTokenGenerator( + newest_jwk_with_rotation( + jwks_directory(app), + int(app.config["JWKS_ROTATION_AGE_DAYS"]))).__call__( + grant_type, + client, + user, + scope, + JWTBearerTokenGenerator.DEFAULT_EXPIRES_IN, + include_refresh_token) return __generator__ + +class JsonAuthorizationServer(AuthorizationServer): + """An authorisation server using JSON rather than FORMDATA.""" + + def create_oauth2_request(self, request): + """Create an OAuth2 Request from the flask request.""" + res = create_oauth_request(request, OAuth2Request, True) + return res + + def setup_oauth2_server(app: Flask) -> None: """Set's up the oauth2 server for the flask application.""" - server = AuthorizationServer() + server = JsonAuthorizationServer() server.register_grant(PasswordGrant) # Figure out a common `code_verifier` for GN2 and GN3 and set @@ -133,11 +153,9 @@ def setup_oauth2_server(app: Flask) -> None: server.init_app( app, query_client=create_query_client_func(), - save_token=create_save_token_func( - OAuth2Token, app.config["SSL_PRIVATE_KEY"])) + save_token=create_save_token_func(OAuth2Token, app)) app.config["OAUTH2_SERVER"] = server ## Set up the token validators - require_oauth.register_token_validator(BearerTokenValidator()) require_oauth.register_token_validator( - JWTBearerTokenValidator(app.config["SSL_PRIVATE_KEY"].get_public_key())) + JWTBearerTokenValidator(KeySet(list_jwks(jwks_directory(app))))) diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py index 22437a2..d0b55b4 100644 --- a/gn_auth/auth/authentication/oauth2/views.py +++ b/gn_auth/auth/authentication/oauth2/views.py @@ -9,6 +9,7 @@ from flask import ( flash, request, url_for, + jsonify, redirect, Response, Blueprint, @@ -17,6 +18,7 @@ from flask import ( from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.db.sqlite3 import with_db_connection +from gn_auth.auth.jwks import jwks_directory, list_jwks from gn_auth.auth.errors import NotFoundError, ForbiddenAccess from gn_auth.auth.authentication.users import valid_login, user_by_email @@ -45,6 +47,14 @@ def authorise(): flash("Invalid OAuth2 client.", "alert-danger") if request.method == "GET": + def __forgot_password_table_exists__(conn): + with db.cursor(conn) as cursor: + cursor.execute("SELECT name FROM sqlite_master " + "WHERE type='table' " + "AND name='forgot_password_tokens'") + return bool(cursor.fetchone()) + return False + client = server.query_client(request.args.get("client_id")) _src = urlparse(request.args["redirect_uri"]) return render_template( @@ -53,7 +63,9 @@ def authorise(): scope=client.scope, response_type=request.args["response_type"], redirect_uri=request.args["redirect_uri"], - source_uri=f"{_src.scheme}://{_src.netloc}/") + source_uri=f"{_src.scheme}://{_src.netloc}/", + display_forgot_password=with_db_connection( + __forgot_password_table_exists__)) form = request.form def __authorise__(conn: db.DbConnection): @@ -72,7 +84,8 @@ def authorise(): url_for("oauth2.users.handle_unverified", response_type=form["response_type"], client_id=client_id, - redirect_uri=form["redirect_uri"]), + redirect_uri=form["redirect_uri"], + email=email["email"]), code=307) return server.create_authorization_response(request=request, grant_user=user) flash(email_passwd_msg, "alert-danger") @@ -116,3 +129,13 @@ def introspect_token() -> Response: IntrospectionEndpoint.ENDPOINT_NAME) raise ForbiddenAccess("You cannot access this endpoint") + + +@auth.route("/public-jwks", methods=["GET"]) +def public_jwks(): + """Provide the JWK public keys used by this application.""" + return jsonify({ + "documentation": ( + "The keys are listed in order of creation, from the oldest (first) " + "to the newest (last)."), + "jwks": tuple(key.as_dict() for key in list_jwks(jwks_directory(app)))}) diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py index ac93049..333ba0d 100644 --- a/gn_auth/auth/authorisation/resources/base.py +++ b/gn_auth/auth/authorisation/resources/base.py @@ -3,6 +3,8 @@ from uuid import UUID from dataclasses import dataclass from typing import Any, Sequence +import sqlite3 + @dataclass(frozen=True) class ResourceCategory: @@ -20,3 +22,15 @@ class Resource: resource_category: ResourceCategory public: bool resource_data: Sequence[dict[str, Any]] = tuple() + + +def resource_from_dbrow(row: sqlite3.Row): + """Convert an SQLite3 resultset row into a resource.""" + return Resource( + resource_id=UUID(row["resource_id"]), + resource_name=row["resource_name"], + resource_category=ResourceCategory( + UUID(row["resource_category_id"]), + row["resource_category_key"], + row["resource_category_description"]), + public=bool(int(row["public"]))) diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py new file mode 100644 index 0000000..5d2b72b --- /dev/null +++ b/gn_auth/auth/authorisation/resources/common.py @@ -0,0 +1,24 @@ +"""Utilities common to more than one resource.""" +import uuid + +from sqlite3 import Cursor + +def assign_resource_owner_role( + cursor: Cursor, + resource_id: uuid.UUID, + user_id: uuid.UUID +) -> dict: + """Assign `user` the 'Resource Owner' role for `resource`.""" + cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'") + role = cursor.fetchone() + params = { + "user_id": str(user_id), + "role_id": role["role_id"], + "resource_id": str(resource_id) + } + cursor.execute( + "INSERT INTO user_roles " + "VALUES (:user_id, :role_id, :resource_id) " + "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", + params) + return params diff --git a/gn_auth/auth/authorisation/resources/genotypes/__init__.py b/gn_auth/auth/authorisation/resources/genotypes/__init__.py new file mode 100644 index 0000000..f401e28 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/genotypes/__init__.py @@ -0,0 +1 @@ +"""Initialise a genotypes resources package.""" diff --git a/gn_auth/auth/authorisation/resources/genotype.py b/gn_auth/auth/authorisation/resources/genotypes/models.py index 206ab61..e8dca9b 100644 --- a/gn_auth/auth/authorisation/resources/genotype.py +++ b/gn_auth/auth/authorisation/resources/genotypes/models.py @@ -5,9 +5,8 @@ from typing import Optional, Sequence import sqlite3 import gn_auth.auth.db.sqlite3 as db - -from .base import Resource -from .data import __attach_data__ +from gn_auth.auth.authorisation.resources.base import Resource +from gn_auth.auth.authorisation.resources.data import __attach_data__ def resource_data( @@ -29,7 +28,7 @@ def link_data_to_resource( conn: db.DbConnection, resource: Resource, data_link_id: uuid.UUID) -> dict: - """Link Genotype data with a resource.""" + """Link Genotype data with a resource using the GUI.""" with db.cursor(conn) as cursor: params = { "resource_id": str(resource.resource_id), @@ -67,3 +66,45 @@ def attach_resources_data( f"WHERE gr.resource_id IN ({placeholders})", tuple(str(resource.resource_id) for resource in resources)) return __attach_data__(cursor.fetchall(), resources) + + +def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments] + cursor, + resource_id: uuid.UUID, + group_id: uuid.UUID, + species_id: int, + population_id: int, + dataset_id: int, + dataset_name: str, + dataset_fullname: str, + dataset_shortname: str +) -> dict: + """Link the genotype identifier data to the genotype resource.""" + params = { + "resource_id": str(resource_id), + "group_id": str(group_id), + "data_link_id": str(uuid.uuid4()), + "species_id": species_id, + "population_id": population_id, + "dataset_id": dataset_id, + "dataset_name": dataset_name, + "dataset_fullname": dataset_fullname, + "dataset_shortname": dataset_shortname + } + cursor.execute( + "INSERT INTO linked_genotype_data " + "VALUES (" + ":data_link_id," + ":group_id," + ":species_id," + ":population_id," + ":dataset_id," + ":dataset_name," + ":dataset_fullname," + ":dataset_shortname" + ")", + params) + cursor.execute( + "INSERT INTO genotype_resources VALUES (:resource_id, :data_link_id)", + params) + return params diff --git a/gn_auth/auth/authorisation/resources/genotypes/views.py b/gn_auth/auth/authorisation/resources/genotypes/views.py new file mode 100644 index 0000000..2beed58 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/genotypes/views.py @@ -0,0 +1,78 @@ +"""Genotype-resources-specific views.""" +import uuid + +from pymonad.either import Left, Right +from flask import jsonify, Blueprint, current_app as app + +from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.requests import request_json + +from gn_auth.auth.authorisation.resources.base import ResourceCategory +from gn_auth.auth.authorisation.resources.request_utils import check_form +from gn_auth.auth.authorisation.resources.groups.models import user_group + +from gn_auth.auth.authentication.oauth2.resource_server import require_oauth + +from gn_auth.auth.authorisation.resources.models import create_resource +from gn_auth.auth.authorisation.resources.common import ( + assign_resource_owner_role) + + +from .models import insert_and_link_data_to_resource + +genobp = Blueprint("genotypes", __name__) + +@genobp.route("genotypes/create", methods=["POST"]) +@require_oauth("profile group resource") +def create_geno_resource(): + """Create a new genotype resource.""" + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + cursor.execute("SELECT * FROM resource_categories " + "WHERE resource_category_key='genotype'") + row = cursor.fetchone() + + return check_form( + request_json(), + "species_id", + "population_id", + "dataset_id", + "dataset_name", + "dataset_fullname", + "dataset_shortname" + ).then( + lambda form: user_group(conn, _token.user).maybe( + Left("No user group found!"), + lambda group: Right({"formdata": form, "group": group})) + ).then( + lambda fdgrp: { + **fdgrp, + "resource": create_resource( + cursor, + f"Genotype — {fdgrp['formdata']['dataset_fullname']}", + ResourceCategory(uuid.UUID(row["resource_category_id"]), + row["resource_category_key"], + row["resource_category_description"]), + _token.user, + fdgrp["group"], + fdgrp["formdata"].get("public", "on") == "on")} + ).then( + lambda fdgrpres: { + **fdgrpres, + "owner_role": assign_resource_owner_role( + cursor, + fdgrpres["resource"].resource_id, + _token.user.user_id)} + ).then( + lambda fdgrpres: insert_and_link_data_to_resource( + cursor, + fdgrpres["resource"].resource_id, + fdgrpres["group"].group_id, + fdgrpres["formdata"]["species_id"], + fdgrpres["formdata"]["population_id"], + fdgrpres["formdata"]["dataset_id"], + fdgrpres["formdata"]["dataset_name"], + fdgrpres["formdata"]["dataset_fullname"], + fdgrpres["formdata"]["dataset_shortname"]) + ).either(lambda error: (jsonify(error), 400), jsonify) diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py index ee77654..3263e37 100644 --- a/gn_auth/auth/authorisation/resources/groups/models.py +++ b/gn_auth/auth/authorisation/resources/groups/models.py @@ -5,6 +5,7 @@ from functools import reduce from dataclasses import dataclass from typing import Any, Sequence, Iterable, Optional +import sqlite3 from flask import g from pymonad.maybe import Just, Maybe, Nothing @@ -63,6 +64,13 @@ class MembershipError(AuthorisationError): super().__init__(f"{type(self).__name__}: {error_description}.") +def db_row_to_group(row: sqlite3.Row) -> Group: + """Convert a database row into a group.""" + return Group(UUID(row["group_id"]), + row["group_name"], + json.loads(row["group_metadata"])) + + def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]: """Returns all the groups that a member belongs to""" query = ( diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py index 401be00..920f504 100644 --- a/gn_auth/auth/authorisation/resources/groups/views.py +++ b/gn_auth/auth/authorisation/resources/groups/views.py @@ -48,7 +48,9 @@ def create_group(): with require_oauth.acquire("profile group") as the_token: group_name=request_json().get("group_name", "").strip() if not bool(group_name): - raise GroupCreationError("Could not create the group.") + raise GroupCreationError( + "Could not create the group. Invalid Group name provided was " + f"`{group_name}`") db_uri = current_app.config["AUTH_DB"] with db.connection(db_uri) as conn: diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py new file mode 100644 index 0000000..de1c18a --- /dev/null +++ b/gn_auth/auth/authorisation/resources/inbredset/models.py @@ -0,0 +1,96 @@ +"""Functions to handle the low-level details regarding populations auth.""" +from uuid import UUID, uuid4 + +import sqlite3 + +from gn_auth.auth.errors import NotFoundError +from gn_auth.auth.authentication.users import User +from gn_auth.auth.authorisation.resources.groups.models import Group +from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory +from gn_auth.auth.authorisation.resources.models import ( + create_resource as _create_resource) + +def create_resource( + cursor: sqlite3.Cursor, + resource_name: str, + user: User, + group: Group, + public: bool +) -> Resource: + """Convenience function to create a resource of type 'inbredset-group'.""" + cursor.execute("SELECT * FROM resource_categories " + "WHERE resource_category_key='inbredset-group'") + category = cursor.fetchone() + if category: + return _create_resource(cursor, + resource_name, + ResourceCategory( + resource_category_id=UUID( + category["resource_category_id"]), + resource_category_key="inbredset-group", + resource_category_description=category[ + "resource_category_description"]), + user, + group, + public) + raise NotFoundError("Could not find a 'inbredset-group' resource category.") + + +def assign_inbredset_group_owner_role( + cursor: sqlite3.Cursor, + resource: Resource, + user: User +) -> Resource: + """ + Assign `user` as `InbredSet Group Owner` is resource category is + 'inbredset-group'. + """ + if resource.resource_category.resource_category_key == "inbredset-group": + cursor.execute( + "SELECT * FROM roles WHERE role_name='inbredset-group-owner'") + role = cursor.fetchone() + cursor.execute( + "INSERT INTO user_roles " + "VALUES(:user_id, :role_id, :resource_id) " + "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", + { + "user_id": str(user.user_id), + "role_id": str(role["role_id"]), + "resource_id": str(resource.resource_id) + }) + + return resource + + +def link_data_to_resource(# pylint: disable=[too-many-arguments] + cursor: sqlite3.Cursor, + resource_id: UUID, + species_id: int, + population_id: int, + population_name: str, + population_fullname: str +) -> dict: + """Link a species population to a resource for auth purposes.""" + params = { + "resource_id": str(resource_id), + "data_link_id": str(uuid4()), + "species_id": species_id, + "population_id": population_id, + "population_name": population_name, + "population_fullname": population_fullname + } + cursor.execute( + "INSERT INTO linked_inbredset_groups " + "VALUES(" + " :data_link_id," + " :species_id," + " :population_id," + " :population_name," + " :population_fullname" + ")", + params) + cursor.execute( + "INSERT INTO inbredset_group_resources " + "VALUES (:resource_id, :data_link_id)", + params) + return params diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py index 444c442..b559105 100644 --- a/gn_auth/auth/authorisation/resources/inbredset/views.py +++ b/gn_auth/auth/authorisation/resources/inbredset/views.py @@ -1,12 +1,22 @@ """Views for InbredSet resources.""" -from flask import jsonify, Response, Blueprint +from pymonad.either import Left, Right, Either +from flask import jsonify, Response, Blueprint, current_app as app + from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.requests import request_json from gn_auth.auth.db.sqlite3 import with_db_connection +from gn_auth.auth.authentication.oauth2.resource_server import require_oauth +from gn_auth.auth.authorisation.resources.groups.models import user_group + +from .models import (create_resource, + link_data_to_resource, + assign_inbredset_group_owner_role) -iset = Blueprint("inbredset", __name__) +popbp = Blueprint("populations", __name__) -@iset.route("/resource-id/<int:speciesid>/<int:inbredsetid>") +@popbp.route("/populations/resource-id/<int:speciesid>/<int:inbredsetid>", + methods=["GET"]) def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response: """Retrieve the resource ID for resource attached to the inbredset.""" def __res_by_iset_id__(conn): @@ -34,3 +44,69 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response: resp.status_code = 404 return resp + + +@popbp.route("/populations/create", methods=["POST"]) +@require_oauth("profile group resource") +def create_population_resource(): + """Create a resource of type 'inbredset-group'.""" + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + + def __check_form__(form, usergroup) -> Either: + """Check form for errors.""" + errors: tuple[str, ...] = tuple() + + species_id = form.get("species_id") + if not bool(species_id): + errors = errors + ("Missing `species_id` value.",) + + population_id = form.get("population_id") + if not bool(population_id): + errors = errors + ("Missing `population_id` value.",) + + population_name = form.get("population_name") + if not bool(population_name): + errors = errors + ("Missing `population_name` value.",) + + population_fullname = form.get("population_fullname") + if not bool(population_fullname): + errors = errors + ("Missing `population_fullname` value.",) + + if bool(errors): + error_messages = "\n\t - ".join(errors) + return Left({ + "error": "Invalid Request Data!", + "error_description": error_messages + }) + + return Right({"formdata": form, "group": usergroup}) + + return user_group(conn, _token.user).then( + lambda group: __check_form__(request_json(), group) + ).then( + lambda formdata: { + **formdata, + "resource": create_resource( + cursor, + f"Population — {formdata['formdata']['population_name']}", + _token.user, + formdata["group"], + formdata["formdata"].get("public", "on") == "on")} + ).then( + lambda resource: { + **resource, + "resource": assign_inbredset_group_owner_role( + cursor, resource["resource"], _token.user)} + ).then( + lambda resource: link_data_to_resource( + cursor, + resource["resource"].resource_id, + resource["formdata"]["species_id"], + resource["formdata"]["population_id"], + resource["formdata"]["population_name"], + resource["formdata"]["population_fullname"]) + ).either( + lambda error: (jsonify(error), 400), + jsonify) diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py index 94e817d..c1748f1 100644 --- a/gn_auth/auth/authorisation/resources/models.py +++ b/gn_auth/auth/authorisation/resources/models.py @@ -16,78 +16,59 @@ from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.errors import NotFoundError, AuthorisationError from .checks import authorised_for -from .base import Resource, ResourceCategory -from .groups.models import Group, GroupRole, user_group, is_group_leader +from .base import Resource, ResourceCategory, resource_from_dbrow +from .common import assign_resource_owner_role +from .groups.models import Group, is_group_leader from .mrna import ( resource_data as mrna_resource_data, attach_resources_data as mrna_attach_resources_data, link_data_to_resource as mrna_link_data_to_resource, unlink_data_from_resource as mrna_unlink_data_from_resource) -from .genotype import ( +from .genotypes.models import ( resource_data as genotype_resource_data, attach_resources_data as genotype_attach_resources_data, link_data_to_resource as genotype_link_data_to_resource, unlink_data_from_resource as genotype_unlink_data_from_resource) -from .phenotype import ( +from .phenotypes.models import ( resource_data as phenotype_resource_data, attach_resources_data as phenotype_attach_resources_data, link_data_to_resource as phenotype_link_data_to_resource, unlink_data_from_resource as phenotype_unlink_data_from_resource) -from .errors import MissingGroupError - -def __assign_resource_owner_role__(cursor, resource, user): - """Assign `user` the 'Resource Owner' role for `resource`.""" - cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'") - role = cursor.fetchone() - cursor.execute( - "INSERT INTO user_roles " - "VALUES (:user_id, :role_id, :resource_id) " - "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", - { - "user_id": str(user.user_id), - "role_id": role["role_id"], - "resource_id": str(resource.resource_id) - }) - - -def resource_from_dbrow(row: sqlite3.Row): - """Convert an SQLite3 resultset row into a resource.""" - return Resource( - resource_id=UUID(row["resource_id"]), - resource_name=row["resource_name"], - resource_category=ResourceCategory( - UUID(row["resource_category_id"]), - row["resource_category_key"], - row["resource_category_description"]), - public=bool(int(row["public"]))) - @authorised_p(("group:resource:create-resource",), error_description="Insufficient privileges to create a resource", oauth2_scope="profile resource") -def create_resource( - conn: db.DbConnection, resource_name: str, - resource_category: ResourceCategory, user: User, - public: bool) -> Resource: +def create_resource(# pylint: disable=[too-many-arguments] + cursor: sqlite3.Cursor, + resource_name: str, + resource_category: ResourceCategory, + user: User, + group: Group, + public: bool +) -> Resource: """Create a resource item.""" - with db.cursor(conn) as cursor: - group = user_group(conn, user).maybe( - False, lambda grp: grp)# type: ignore[misc, arg-type] - if not group: - raise MissingGroupError(# Not all resources require an owner group - "User with no group cannot create a resource.") - resource = Resource(uuid4(), resource_name, resource_category, public) - cursor.execute( - "INSERT INTO resources VALUES (?, ?, ?, ?)", - (str(resource.resource_id), - resource_name, - str(resource.resource_category.resource_category_id), - 1 if resource.public else 0)) - cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) " - "VALUES (?, ?)", - (str(group.group_id), str(resource.resource_id))) - __assign_resource_owner_role__(cursor, resource, user) + resource = Resource(uuid4(), resource_name, resource_category, public) + cursor.execute( + "INSERT INTO resources VALUES (?, ?, ?, ?)", + (str(resource.resource_id), + resource_name, + str(resource.resource_category.resource_category_id), + 1 if resource.public else 0)) + # TODO: @fredmanglis,@rookie101 + # 1. Move the actions below into a (the?) hooks system + # 2. Do more checks: A resource can have varying hooks depending on type + # e.g. if mRNA, pheno or geno resource, assign: + # - "resource-owner" + # if inbredset-group, assign: + # - "resource-owner", + # - "inbredset-group-owner" etc. + # if resource is of type "group", assign: + # - group-leader + cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) " + "VALUES (?, ?)", + (str(group.group_id), str(resource.resource_id))) + assign_resource_owner_role(cursor, resource.resource_id, user.user_id) return resource @@ -152,8 +133,10 @@ def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]: """List the resources available to the user""" with db.cursor(conn) as cursor: cursor.execute( - ("SELECT r.*, rc.resource_category_key, " - "rc.resource_category_description FROM user_roles AS ur " + ("SELECT DISTINCT(r.resource_id), r.resource_name, " + "r.resource_category_id, r.public, rc.resource_category_key, " + "rc.resource_category_description " + "FROM user_roles AS ur " "INNER JOIN resources AS r ON ur.resource_id=r.resource_id " "INNER JOIN resource_categories AS rc " "ON r.resource_category_id=rc.resource_category_id " @@ -293,13 +276,13 @@ def attach_resources_data( for category, rscs in organised.items()) for resource in categories) -@authorised_p( - ("group:user:assign-role",), - "You cannot assign roles to users for this group.", - oauth2_scope="profile group role resource") + def assign_resource_user( - conn: db.DbConnection, resource: Resource, user: User, - role: GroupRole) -> dict: + conn: db.DbConnection, + resource: Resource, + user: User, + role: Role +) -> dict: """Assign `role` to `user` for the specific `resource`.""" with db.cursor(conn) as cursor: cursor.execute( @@ -307,39 +290,36 @@ def assign_resource_user( "VALUES (?, ?, ?) " "ON CONFLICT (user_id, role_id, resource_id) " "DO NOTHING", - (str(user.user_id), str(role.role.role_id), - str(resource.resource_id))) + (str(user.user_id), str(role.role_id), str(resource.resource_id))) return { "resource": asdict(resource), "user": asdict(user), "role": asdict(role), "description": ( f"The user '{user.name}'({user.email}) was assigned the " - f"'{role.role.role_name}' role on resource with ID " + f"'{role.role_name}' role on resource with ID " f"'{resource.resource_id}'.")} -@authorised_p( - ("group:user:assign-role",), - "You cannot assign roles to users for this group.", - oauth2_scope="profile group role resource") + def unassign_resource_user( - conn: db.DbConnection, resource: Resource, user: User, - role: GroupRole) -> dict: + conn: db.DbConnection, + resource: Resource, + user: User, + role: Role +) -> dict: """Assign `role` to `user` for the specific `resource`.""" with db.cursor(conn) as cursor: cursor.execute( "DELETE FROM user_roles " "WHERE user_id=? AND role_id=? AND resource_id=?", - (str(user.user_id), - str(role.role.role_id), - str(resource.resource_id))) + (str(user.user_id), str(role.role_id), str(resource.resource_id))) return { "resource": asdict(resource), "user": asdict(user), "role": asdict(role), "description": ( f"The user '{user.name}'({user.email}) had the " - f"'{role.role.role_name}' role on resource with ID " + f"'{role.role_name}' role on resource with ID " f"'{resource.resource_id}' taken away.")} def save_resource( diff --git a/gn_auth/auth/authorisation/resources/phenotype.py b/gn_auth/auth/authorisation/resources/phenotype.py deleted file mode 100644 index 7005db3..0000000 --- a/gn_auth/auth/authorisation/resources/phenotype.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Phenotype data resources functions and utilities.""" -import uuid -from typing import Optional, Sequence - -import sqlite3 - -import gn_auth.auth.db.sqlite3 as db - -from .base import Resource -from .data import __attach_data__ - -def resource_data( - cursor: db.DbCursor, - resource_id: uuid.UUID, - offset: int = 0, - limit: Optional[int] = None) -> Sequence[sqlite3.Row]: - """Fetch data linked to a Phenotype resource""" - cursor.execute( - ("SELECT * FROM phenotype_resources AS pr " - "INNER JOIN linked_phenotype_data AS lpd " - "ON pr.data_link_id=lpd.data_link_id " - "WHERE pr.resource_id=?") + ( - f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""), - (str(resource_id),)) - return cursor.fetchall() - -def link_data_to_resource( - conn: db.DbConnection, - resource: Resource, - data_link_id: uuid.UUID) -> dict: - """Link Phenotype data with a resource.""" - with db.cursor(conn) as cursor: - params = { - "resource_id": str(resource.resource_id), - "data_link_id": str(data_link_id) - } - cursor.execute( - "INSERT INTO phenotype_resources VALUES" - "(:resource_id, :data_link_id)", - params) - return params - -def unlink_data_from_resource( - conn: db.DbConnection, - resource: Resource, - data_link_id: uuid.UUID) -> dict: - """Unlink data from Phenotype resources""" - with db.cursor(conn) as cursor: - cursor.execute("DELETE FROM phenotype_resources " - "WHERE resource_id=? AND data_link_id=?", - (str(resource.resource_id), str(data_link_id))) - return { - "resource_id": str(resource.resource_id), - "dataset_type": resource.resource_category.resource_category_key, - "data_link_id": str(data_link_id) - } - -def attach_resources_data( - cursor, resources: Sequence[Resource]) -> Sequence[Resource]: - """Attach linked data to Phenotype resources""" - placeholders = ", ".join(["?"] * len(resources)) - cursor.execute( - "SELECT * FROM phenotype_resources AS pr " - "INNER JOIN linked_phenotype_data AS lpd " - "ON pr.data_link_id=lpd.data_link_id " - f"WHERE pr.resource_id IN ({placeholders})", - tuple(str(resource.resource_id) for resource in resources)) - return __attach_data__(cursor.fetchall(), resources) diff --git a/gn_auth/auth/authorisation/resources/phenotypes/__init__.py b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py new file mode 100644 index 0000000..0d4dbfa --- /dev/null +++ b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py @@ -0,0 +1 @@ +"""The phenotypes package.""" diff --git a/gn_auth/auth/authorisation/resources/phenotypes/models.py b/gn_auth/auth/authorisation/resources/phenotypes/models.py new file mode 100644 index 0000000..d4a516a --- /dev/null +++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py @@ -0,0 +1,142 @@ +"""Phenotype data resources functions and utilities.""" +import uuid +from functools import reduce +from typing import Optional, Sequence + +import sqlite3 +from pymonad.maybe import Just, Maybe, Nothing +from pymonad.tools import monad_from_none_or_value + +import gn_auth.auth.db.sqlite3 as db +from gn_auth.auth.authorisation.resources.data import __attach_data__ +from gn_auth.auth.authorisation.resources.base import Resource, resource_from_dbrow + +def resource_data( + cursor: db.DbCursor, + resource_id: uuid.UUID, + offset: int = 0, + limit: Optional[int] = None) -> Sequence[sqlite3.Row]: + """Fetch data linked to a Phenotype resource""" + cursor.execute( + ("SELECT * FROM phenotype_resources AS pr " + "INNER JOIN linked_phenotype_data AS lpd " + "ON pr.data_link_id=lpd.data_link_id " + "WHERE pr.resource_id=?") + ( + f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""), + (str(resource_id),)) + return cursor.fetchall() + +def link_data_to_resource( + conn: db.DbConnection, + resource: Resource, + data_link_id: uuid.UUID) -> dict: + """Link Phenotype data with a resource.""" + with db.cursor(conn) as cursor: + params = { + "resource_id": str(resource.resource_id), + "data_link_id": str(data_link_id) + } + cursor.execute( + "INSERT INTO phenotype_resources VALUES" + "(:resource_id, :data_link_id)", + params) + return params + +def unlink_data_from_resource( + conn: db.DbConnection, + resource: Resource, + data_link_id: uuid.UUID) -> dict: + """Unlink data from Phenotype resources""" + with db.cursor(conn) as cursor: + cursor.execute("DELETE FROM phenotype_resources " + "WHERE resource_id=? AND data_link_id=?", + (str(resource.resource_id), str(data_link_id))) + return { + "resource_id": str(resource.resource_id), + "dataset_type": resource.resource_category.resource_category_key, + "data_link_id": str(data_link_id) + } + +def attach_resources_data( + cursor, resources: Sequence[Resource]) -> Sequence[Resource]: + """Attach linked data to Phenotype resources""" + placeholders = ", ".join(["?"] * len(resources)) + cursor.execute( + "SELECT * FROM phenotype_resources AS pr " + "INNER JOIN linked_phenotype_data AS lpd " + "ON pr.data_link_id=lpd.data_link_id " + f"WHERE pr.resource_id IN ({placeholders})", + tuple(str(resource.resource_id) for resource in resources)) + return __attach_data__(cursor.fetchall(), resources) + + +def individual_linked_resource( + conn: db.DbConnection, + species_id: int, + population_id: int, + dataset_id: int, + xref_id: str) -> Maybe: + """Given the data details, return the linked resource, if one is defined.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT " + "rsc.*, rc.*, lpd.SpeciesId AS species_id, " + "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, " + "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname " + "FROM linked_phenotype_data AS lpd " + "INNER JOIN phenotype_resources AS pr " + "ON lpd.data_link_id=pr.data_link_id " + "INNER JOIN resources AS rsc ON pr.resource_id=rsc.resource_id " + "INNER JOIN resource_categories AS rc " + "ON rsc.resource_category_id=rc.resource_category_id " + "WHERE " + "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId, lpd.PublishXRefId) = " + "(?, ?, ?, ?)", + (species_id, population_id, dataset_id, xref_id)) + return monad_from_none_or_value( + Nothing, Just, cursor.fetchone()).then(resource_from_dbrow) + + +def all_linked_resources( + conn: db.DbConnection, + species_id: int, + population_id: int, + dataset_id: int) -> Maybe: + """Given the data details, return the linked resource, if one is defined.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT rsc.*, rc.resource_category_key, " + "rc.resource_category_description, lpd.SpeciesId AS species_id, " + "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, " + "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname " + "FROM linked_phenotype_data AS lpd " + "INNER JOIN phenotype_resources AS pr " + "ON lpd.data_link_id=pr.data_link_id INNER JOIN resources AS rsc " + "ON pr.resource_id=rsc.resource_id " + "INNER JOIN resource_categories AS rc " + "ON rsc.resource_category_id=rc.resource_category_id " + "WHERE " + "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId) = (?, ?, ?)", + (species_id, population_id, dataset_id)) + + _rscdatakeys = ( + "species_id", "population_id", "xref_id", "dataset_name", + "dataset_fullname", "dataset_shortname") + def __organise__(resources, row): + _rscid = uuid.UUID(row["resource_id"]) + _resource = resources.get(_rscid, resource_from_dbrow(row)) + return { + **resources, + _rscid: Resource( + _resource.resource_id, + _resource.resource_name, + _resource.resource_category, + _resource.public, + _resource.resource_data + ( + {key: row[key] for key in _rscdatakeys},)) + } + results: dict[uuid.UUID, Resource] = reduce( + __organise__, cursor.fetchall(), {}) + if len(results) == 0: + return Nothing + return Just(tuple(results.values())) diff --git a/gn_auth/auth/authorisation/resources/phenotypes/views.py b/gn_auth/auth/authorisation/resources/phenotypes/views.py new file mode 100644 index 0000000..a971d2b --- /dev/null +++ b/gn_auth/auth/authorisation/resources/phenotypes/views.py @@ -0,0 +1,75 @@ +"""Views for the phenotype resources.""" +from pymonad.either import Left, Right +from flask import jsonify, Blueprint, current_app as app + +from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.requests import request_json +from gn_auth.auth.authorisation.resources.request_utils import check_form +from gn_auth.auth.authorisation.roles.models import user_roles_on_resource + +from gn_auth.auth.authentication.oauth2.resource_server import require_oauth + +from .models import all_linked_resources, individual_linked_resource + +phenobp = Blueprint("phenotypes", __name__) + +@phenobp.route("/phenotypes/individual/linked-resource", methods=["POST"]) +def get_individual_linked_resource(): + """Get the linked resource for a particular phenotype within the dataset. + + Phenotypes are a tad tricky. Each phenotype could technically be a resource + on its own, and thus a user could have access to only a subset of phenotypes + within the entire dataset.""" + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn): + return check_form( + request_json(), + "species_id", + "population_id", + "dataset_id", + "xref_id" + ).then( + lambda formdata: individual_linked_resource( + conn, + int(formdata["species_id"]), + int(formdata["population_id"]), + int(formdata["dataset_id"]), + formdata["xref_id"] + ).maybe(Left("No linked resource!"), + lambda lrsc: Right({ + "formdata": formdata, + "resource": lrsc + })) + ).then( + lambda fdlrsc: { + **fdlrsc, + "roles": user_roles_on_resource( + conn, _token.user.user_id, fdlrsc["resource"].resource_id) + } + ).either(lambda error: (jsonify(error), 400), + lambda res: jsonify({ + key: value for key, value in res.items() + if key != "formdata" + })) + + +@phenobp.route("/phenotypes/linked-resources", methods=["POST"]) +def get_all_linked_resources(): + """Get all the linked resources for all phenotypes within a dataset. + + See `get_individual_linked_resource(…)` documentation.""" + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn): + return check_form( + request_json(), + "species_id", + "population_id", + "dataset_id" + ).then( + lambda formdata: all_linked_resources( + conn, + int(formdata["species_id"]), + int(formdata["population_id"]), + int(formdata["dataset_id"])).maybe( + Left("No linked resource!"), Right) + ).either(lambda error: (jsonify(error), 400), jsonify) diff --git a/gn_auth/auth/authorisation/resources/request_utils.py b/gn_auth/auth/authorisation/resources/request_utils.py new file mode 100644 index 0000000..ade779e --- /dev/null +++ b/gn_auth/auth/authorisation/resources/request_utils.py @@ -0,0 +1,20 @@ +"""Some common utils for requests to the resources endpoints.""" +from functools import reduce + +from pymonad.either import Left, Right, Either + +def check_form(form, *fields) -> Either: + """Check form for errors""" + def __check_field__(errors, field): + if not bool(form.get(field)): + return errors + (f"Missing `{field}` value.",) + return errors + + errors: tuple[str, ...] = reduce(__check_field__, fields, tuple()) + if len(errors) > 0: + return Left({ + "error": "Invalid request data!", + "error_description": "\n\t - ".join(errors) + }) + + return Right(form) diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py index cf9ebc4..1c4104a 100644 --- a/gn_auth/auth/authorisation/resources/views.py +++ b/gn_auth/auth/authorisation/resources/views.py @@ -18,6 +18,7 @@ from gn_auth.auth.requests import request_json from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.db.sqlite3 import with_db_connection +from gn_auth.auth.jwks import newest_jwk, jwks_directory from gn_auth.auth.authorisation.roles import Role from gn_auth.auth.authorisation.roles.models import ( @@ -39,15 +40,22 @@ from gn_auth.auth.authentication.oauth2.resource_server import require_oauth from gn_auth.auth.authentication.users import User, user_by_id, user_by_email from .checks import authorised_for +from .inbredset.views import popbp +from .genotypes.views import genobp +from .phenotypes.views import phenobp +from .errors import MissingGroupError +from .groups.models import Group, user_group from .models import ( Resource, resource_data, resource_by_id, public_resources, resource_categories, assign_resource_user, link_data_to_resource, unassign_resource_user, resource_category_by_id, user_roles_on_resources, unlink_data_from_resource, create_resource as _create_resource, get_resource_id) -from .groups.models import Group, resource_owner, group_role_by_id resources = Blueprint("resources", __name__) +resources.register_blueprint(popbp, url_prefix="/") +resources.register_blueprint(genobp, url_prefix="/") +resources.register_blueprint(phenobp, url_prefix="/") @resources.route("/categories", methods=["GET"]) @require_oauth("profile group resource") @@ -67,13 +75,20 @@ def create_resource() -> Response: resource_name = form.get("resource_name") resource_category_id = UUID(form.get("resource_category")) db_uri = app.config["AUTH_DB"] - with db.connection(db_uri) as conn: + with (db.connection(db_uri) as conn, + db.cursor(conn) as cursor): try: + group = user_group(conn, the_token.user).maybe( + False, lambda grp: grp)# type: ignore[misc, arg-type] + if not group: + raise MissingGroupError(# Not all resources require an owner group + "User with no group cannot create a resource.") resource = _create_resource( - conn, + cursor, resource_name, resource_category_by_id(conn, resource_category_id), the_token.user, + group, (form.get("public") == "on")) return jsonify(asdict(resource)) except sqlite3.IntegrityError as sql3ie: @@ -247,22 +262,25 @@ def resource_users(resource_id: UUID): @require_oauth("profile group resource role") def assign_role_to_user(resource_id: UUID) -> Response: """Assign a role on the specified resource to a user.""" - with require_oauth.acquire("profile group resource role") as the_token: + with require_oauth.acquire("profile group resource role") as _token: try: form = request_json() - group_role_id = form.get("group_role_id", "") + role_id = form.get("role_id", "") user_email = form.get("user_email", "") - assert bool(group_role_id), "The role must be provided." + assert bool(role_id), "The role must be provided." assert bool(user_email), "The user email must be provided." def __assign__(conn: db.DbConnection) -> dict: - resource = resource_by_id(conn, the_token.user, resource_id) + authorised_for( + conn, + _token.user, + ("resource:role:assign-role",), + (resource_id,)) + resource = resource_by_id(conn, _token.user, resource_id) user = user_by_email(conn, user_email) return assign_resource_user( conn, resource, user, - group_role_by_id(conn, - resource_owner(conn, resource), - UUID(group_role_id))) + role_by_id(conn, UUID(role_id)))# type: ignore[arg-type] except AssertionError as aserr: raise AuthorisationError(aserr.args[0]) from aserr @@ -272,21 +290,24 @@ def assign_role_to_user(resource_id: UUID) -> Response: @require_oauth("profile group resource role") def unassign_role_to_user(resource_id: UUID) -> Response: """Unassign a role on the specified resource from a user.""" - with require_oauth.acquire("profile group resource role") as the_token: + with require_oauth.acquire("profile group resource role") as _token: try: form = request_json() - group_role_id = form.get("group_role_id", "") + role_id = form.get("role_id", "") user_id = form.get("user_id", "") - assert bool(group_role_id), "The role must be provided." + assert bool(role_id), "The role must be provided." assert bool(user_id), "The user id must be provided." def __assign__(conn: db.DbConnection) -> dict: - resource = resource_by_id(conn, the_token.user, resource_id) + authorised_for( + conn, + _token.user, + ("resource:role:assign-role",), + (resource_id,)) + resource = resource_by_id(conn, _token.user, resource_id) return unassign_resource_user( conn, resource, user_by_id(conn, UUID(user_id)), - group_role_by_id(conn, - resource_owner(conn, resource), - UUID(group_role_id))) + role_by_id(conn, UUID(role_id)))# type: ignore[arg-type] except AssertionError as aserr: raise AuthorisationError(aserr.args[0]) from aserr @@ -390,9 +411,18 @@ def resource_roles(resource_id: UUID) -> Response: "ON rp.privilege_id=p.privilege_id " "WHERE rr.resource_id=? AND rr.role_created_by=?", (str(resource_id), str(_token.user.user_id))) - results = cursor.fetchall() + user_created = db_rows_to_roles(cursor.fetchall()) - return db_rows_to_roles(results) + cursor.execute( + "SELECT ur.user_id, ur.resource_id, r.*, p.* FROM user_roles AS ur " + "INNER JOIN roles AS r ON ur.role_id=r.role_id " + "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id " + "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id " + "WHERE resource_id=? AND user_id=?", + (str(resource_id), str(_token.user.user_id))) + assigned_to_user = db_rows_to_roles(cursor.fetchall()) + + return assigned_to_user + user_created return jsonify(with_db_connection(__roles__)) @@ -433,6 +463,14 @@ def resources_authorisation(): "Expected a JSON object with a 'resource-ids' key.") }) resp.status_code = 400 + except Exception as _exc:#pylint: disable=[broad-except] + app.logger.debug("Generic exception.", exc_info=True) + resp = jsonify({ + "status": "general-exception", + "error_description": ( + "Failed to fetch the user's privileges.") + }) + resp.status_code = 500 return resp @@ -485,7 +523,8 @@ def get_user_roles_on_resource(name) -> Response: "email": _token.user.email, "roles": roles, } - token = jwt.encode(jose_header, payload, app.config["SSL_PRIVATE_KEY"]) + token = jwt.encode( + jose_header, payload, newest_jwk(jwks_directory(app))) response.headers["Authorization"] = f"Bearer {token.decode('utf-8')}" return response diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py index dc1dfdc..2729b3b 100644 --- a/gn_auth/auth/authorisation/roles/models.py +++ b/gn_auth/auth/authorisation/roles/models.py @@ -133,10 +133,10 @@ def user_roles(conn: db.DbConnection, user: User) -> Sequence[dict]: return tuple() -def user_resource_roles( +def user_roles_on_resource( conn: db.DbConnection, - user: User, - resource: Resource + user_id: UUID, + resource_id: UUID ) -> tuple[Role, ...]: """Retrieve all roles assigned to a user for a particular resource.""" with db.cursor(conn) as cursor: @@ -147,12 +147,22 @@ def user_resource_roles( "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id " "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id " "WHERE ur.user_id=? AND ur.resource_id=?", - (str(user.user_id), str(resource.resource_id))) + (str(user_id), str(resource_id))) return db_rows_to_roles(cursor.fetchall()) return tuple() +def user_resource_roles( + conn: db.DbConnection, + user: User, + resource: Resource +) -> tuple[Role, ...]: + "Retrieve roles a user has on a particular resource." + # TODO: Temporary placeholder to prevent system from breaking. + return user_roles_on_resource(conn, user.user_id, resource.resource_id) + + def user_role(conn: db.DbConnection, user: User, role_id: UUID) -> Either: """Retrieve a specific non-resource role assigned to the user.""" with db.cursor(conn) as cursor: diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py index 8ca1e51..1dd2f1f 100644 --- a/gn_auth/auth/authorisation/users/admin/views.py +++ b/gn_auth/auth/authorisation/users/admin/views.py @@ -3,14 +3,12 @@ import uuid import json import random import string -from pathlib import Path from typing import Optional from functools import partial from dataclasses import asdict from urllib.parse import urlparse from datetime import datetime, timezone, timedelta -from authlib.jose import KeySet, JsonWebKey from email_validator import validate_email, EmailNotValidError from flask import ( flash, @@ -32,6 +30,7 @@ from ....authentication.oauth2.models.oauth2client import ( save_client, OAuth2Client, oauth2_clients, + update_client_attribute, client as oauth2_client, delete_client as _delete_client) from ....authentication.users import ( @@ -62,7 +61,8 @@ _FORM_GRANT_TYPES_ = ({ @admin.before_request def update_expires(): """Update session expiration.""" - if session.session_info() and not session.update_expiry(): + if (session.session_info() and not session.update_expiry( + int(app.config.get("SESSION_EXPIRY_MINUTES", 10)))): flash("Session has expired. Logging out...", "alert-warning") session.clear_session_info() return redirect(url_for("oauth2.admin.login")) @@ -96,7 +96,8 @@ def login(): session.update_session_info( user=asdict(user), expires=( - datetime.now(tz=timezone.utc) + timedelta(minutes=10))) + datetime.now(tz=timezone.utc) + timedelta(minutes=int( + app.config.get("SESSION_EXPIRY_MINUTES", 10))))) return redirect(url_for(next_uri)) raise NotFoundError(error_message) except NotFoundError as _nfe: @@ -176,6 +177,9 @@ def check_register_client_form(form): "scope[]", "You need to select at least one scope option."),) + if not uri_valid(form.get("client_jwk_uri", "")): + errors = errors + ("The provided client's public JWKs URI is invalid.",) + errors = tuple(item for item in errors if item is not None) if bool(errors): raise RegisterClientError(errors) @@ -223,7 +227,8 @@ def register_client(): "default_redirect_uri": default_redirect_uri, "redirect_uris": [default_redirect_uri] + form.get("other_redirect_uri", "").split(), "response_type": __response_types__(tuple(grant_types)), - "scope": form.getlist("scope[]") + "scope": form.getlist("scope[]"), + "public-jwks-uri": form.get("client_jwk_uri", "") }, user = with_db_connection(partial( user_by_id, user_id=uuid.UUID(form["user"]))) @@ -260,108 +265,6 @@ def view_client(client_id: uuid.UUID): scope=app.config["OAUTH2_SCOPE"], granttypes=_FORM_GRANT_TYPES_) -@admin.route("/register-client-public-key", methods=["POST"]) -@is_admin -def register_client_public_key(): - """Register a client's SSL key""" - form = request.form - admin_dashboard_uri = redirect(url_for("oauth2.admin.dashboard")) - view_client_uri = redirect(url_for("oauth2.admin.view_client", - client_id=form["client_id"])) - if not bool(form.get("client_id")): - flash("No client selected.", "alert-danger") - return admin_dashboard_uri - - try: - _client = with_db_connection(partial( - oauth2_client, client_id=uuid.UUID(form["client_id"]))) - if _client.is_nothing(): - raise ValueError("No such client.") - _client = _client.value - except ValueError: - flash("Invalid client ID provided.", "alert-danger") - return admin_dashboard_uri - try: - _key = JsonWebKey.import_key(form["client_ssl_key"].strip()) - except ValueError: - flash("Invalid key provided!", "alert-danger") - return view_client_uri - - keypath = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"]).joinpath( - f"{_key.thumbprint()}.pem") - if not keypath.exists(): - with open(keypath, mode="w", encoding="utf8") as _kpth: - _kpth.write(form["client_ssl_key"]) - - with_db_connection(partial(save_client, the_client=OAuth2Client( - client_id=_client.client_id, - client_secret=_client.client_secret, - client_id_issued_at=_client.client_id_issued_at, - client_secret_expires_at=_client.client_secret_expires_at, - client_metadata={ - **_client.client_metadata, - "public_keys": list(set( - _client.client_metadata.get("public_keys", []) + - [str(keypath)]))}, - user=_client.user))) - flash("Client key successfully registered.", "alert-success") - return view_client_uri - - -@admin.route("/delete-client-public-key", methods=["POST"]) -@is_admin -def delete_client_public_key(): - """Delete a client's SSL key""" - form = request.form - admin_dashboard_uri = redirect(url_for("oauth2.admin.dashboard")) - view_client_uri = redirect(url_for("oauth2.admin.view_client", - client_id=form["client_id"])) - if not bool(form.get("client_id")): - flash("No client selected.", "alert-danger") - return admin_dashboard_uri - - try: - _client = with_db_connection(partial( - oauth2_client, client_id=uuid.UUID(form["client_id"]))) - if _client.is_nothing(): - raise ValueError("No such client.") - _client = _client.value - except ValueError: - flash("Invalid client ID provided.", "alert-danger") - return admin_dashboard_uri - - if form.get("ssl_key", None) is None: - flash("The key must be provided.", "alert-danger") - return view_client_uri - - try: - def find_by_kid(keyset: KeySet, kid: str) -> JsonWebKey: - for key in keyset.keys: - if key.thumbprint() == kid: - return key - raise ValueError('Invalid JSON Web Key Set') - _key = find_by_kid(_client.jwks, form.get("ssl_key")) - except ValueError: - flash("Could not delete: No such public key.", "alert-danger") - return view_client_uri - - _keys = (_key for _key in _client.jwks.keys - if _key.thumbprint() != form["ssl_key"]) - _keysdir = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"]) - with_db_connection(partial(save_client, the_client=OAuth2Client( - client_id=_client.client_id, - client_secret=_client.client_secret, - client_id_issued_at=_client.client_id_issued_at, - client_secret_expires_at=_client.client_secret_expires_at, - client_metadata={ - **_client.client_metadata, - "public_keys": list(set( - _keysdir.joinpath(f"{_key.thumbprint()}.pem") - for _key in _keys))}, - user=_client.user))) - flash("Key deleted.", "alert-success") - return view_client_uri - @admin.route("/edit-client", methods=["POST"]) @is_admin @@ -389,7 +292,8 @@ def edit_client(): [form["redirect_uri"]] + form["other_redirect_uris"].split("\r\n"))), "grant_types": form.getlist("grants[]"), - "scope": form.getlist("scope[]") + "scope": form.getlist("scope[]"), + "public-jwks-uri": form.get("client_jwk_uri", "") } with_db_connection(partial(save_client, the_client=OAuth2Client( the_client.client_id, @@ -418,3 +322,36 @@ def delete_client(): "successfully."), "alert-success") return redirect(url_for("oauth2.admin.list_clients")) + + +@admin.route("/clients/<uuid:client_id>/change-secret", methods=["GET", "POST"]) +@is_admin +def change_client_secret(client_id: uuid.UUID): + def __no_client__(): + # Calling the function causes the flash to be evaluated + # flash("No such client was found!", "alert-danger") + return redirect(url_for("oauth2.admin.list_clients")) + + with db.connection(app.config["AUTH_DB"]) as conn: + if request.method == "GET": + return oauth2_client( + conn, client_id=client_id + ).maybe(__no_client__(), lambda _client: render_template( + "admin/confirm-change-client-secret.html", + client=_client + )) + + _raw = random_string() + return oauth2_client( + conn, client_id=client_id + ).then( + lambda _client: save_client( + conn, + update_client_attribute( + _client, "client_secret", hash_password(_raw))) + ).then( + lambda _client: render_template( + "admin/registered-client.html", + client=_client, + client_secret=_raw) + ).maybe(__no_client__(), lambda resp: resp) diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py index b4a24f3..f0a7fa2 100644 --- a/gn_auth/auth/authorisation/users/collections/models.py +++ b/gn_auth/auth/authorisation/users/collections/models.py @@ -205,8 +205,10 @@ def add_traits(rconn: Redis, mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id) __raise_if_not_single_collection__(user, collection_id, mod_col) new_members = tuple(set(tuple(mod_col[0]["members"]) + traits)) + now = datetime.utcnow() new_coll = { **mod_col[0], + "changed": now, "members": new_members, "num_members": len(new_members) } @@ -233,8 +235,10 @@ def remove_traits(rconn: Redis, __raise_if_not_single_collection__(user, collection_id, mod_col) new_members = tuple( trait for trait in mod_col[0]["members"] if trait not in traits) + now = datetime.utcnow() new_coll = { **mod_col[0], + "changed": now, "members": new_members, "num_members": len(new_members) } diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py index eeae91d..f619c3d 100644 --- a/gn_auth/auth/authorisation/users/collections/views.py +++ b/gn_auth/auth/authorisation/users/collections/views.py @@ -113,6 +113,7 @@ def import_anonymous() -> Response: anon_id = UUID(request.json.get("anon_id"))#type: ignore[union-attr] anon_colls = user_collections(redisconn, User( anon_id, "anon@ymous.user", "Anonymous User")) + anon_colls = tuple(coll for coll in anon_colls if coll['num_members'] > 0) save_collections( redisconn, token.user, diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py index 57bc564..8ac1a68 100644 --- a/gn_auth/auth/authorisation/users/masquerade/models.py +++ b/gn_auth/auth/authorisation/users/masquerade/models.py @@ -1,13 +1,16 @@ """Functions for handling masquerade.""" -from uuid import uuid4 +import uuid from functools import wraps from datetime import datetime +from authlib.jose import jwt from flask import current_app as app from gn_auth.auth.errors import ForbiddenAccess +from gn_auth.auth.jwks import newest_jwk_with_rotation, jwks_directory + from ...roles.models import user_roles from ....db import sqlite3 as db from ....authentication.users import User @@ -31,9 +34,13 @@ def can_masquerade(func): conn = kwargs["conn"] token = kwargs["original_token"] - masq_privs = [priv for role in user_roles(conn, token.user) - for priv in role.privileges - if priv.privilege_id == "system:user:masquerade"] + masq_privs = [] + for roles in user_roles(conn, token.user): + for role in roles["roles"]: + privileges = [p for p in role.privileges + if p.privilege_id == "system:user:masquerade"] + masq_privs.extend(privileges) + if len(masq_privs) == 0: raise ForbiddenAccess( "You do not have the ability to masquerade as another user.") @@ -52,8 +59,14 @@ def masquerade_as( user=masqueradee, expires_in=__FIVE_HOURS__, include_refresh_token=True) + + _jwt = jwt.decode( + original_token.access_token, + newest_jwk_with_rotation( + jwks_directory(app), + int(app.config["JWKS_ROTATION_AGE_DAYS"]))) new_token = OAuth2Token( - token_id=uuid4(), + token_id=uuid.UUID(_jwt["jti"]), client=original_token.client, token_type=token_details["token_type"], access_token=token_details["access_token"], diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py index 276859a..68f19ee 100644 --- a/gn_auth/auth/authorisation/users/masquerade/views.py +++ b/gn_auth/auth/authorisation/users/masquerade/views.py @@ -33,13 +33,13 @@ def masquerade() -> Response: return new_token def __dump_token__(tok): return { - key: value for key, value in (tok._asdict().items()) + key: value for key, value in asdict(tok).items() if key in ("access_token", "refresh_token", "expires_in", "token_type") } return jsonify({ "original": { - "user": token.user._asdict(), + "user": asdict(token.user), "token": __dump_token__(token) }, "masquerade_as": { diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py index 8135ed3..7adcd06 100644 --- a/gn_auth/auth/authorisation/users/views.py +++ b/gn_auth/auth/authorisation/users/views.py @@ -1,12 +1,13 @@ """User authorisation endpoints.""" +import uuid import sqlite3 import secrets -import datetime import traceback from typing import Any from functools import partial from dataclasses import asdict from urllib.parse import urljoin +from datetime import datetime, timedelta from email.headerregistry import Address from email_validator import validate_email, EmailNotValidError from flask import ( @@ -123,7 +124,7 @@ def send_verification_email( """Send an email verification message.""" subject="GeneNetwork: Please Verify Your Email" verification_code = secrets.token_urlsafe(64) - generated = datetime.datetime.now() + generated = datetime.now() expiration_minutes = 15 def __render__(template): return render_template(template, @@ -148,12 +149,13 @@ def send_verification_email( "generated": int(generated.timestamp()), "expires": int( (generated + - datetime.timedelta( + timedelta( minutes=expiration_minutes)).timestamp()) }) - send_message(smtp_user=current_app.config["SMTP_USER"], - smtp_passwd=current_app.config["SMTP_PASSWORD"], + send_message(smtp_user=current_app.config.get("SMTP_USER", ""), + smtp_passwd=current_app.config.get("SMTP_PASSWORD", ""), message=build_email_message( + from_address=current_app.config["EMAIL_ADDRESS"], to_addresses=(user_address(user),), subject=subject, txtmessage=__render__("emails/verify-email.txt"), @@ -187,11 +189,11 @@ def register_user() -> Response: redirect_uri=form["redirect_uri"]) return jsonify(asdict(user)) except sqlite3.IntegrityError as sq3ie: - current_app.logger.debug(traceback.format_exc()) + current_app.logger.error(traceback.format_exc()) raise UserRegistrationError( "A user with that email already exists") from sq3ie except EmailNotValidError as enve: - current_app.logger.debug(traceback.format_exc()) + current_app.logger.error(traceback.format_exc()) raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve raise Exception( @@ -235,11 +237,12 @@ def verify_user(): return loginuri results = results[0] - if (datetime.datetime.fromtimestamp( - int(results["expires"])) < datetime.datetime.now()): + if (datetime.fromtimestamp( + int(results["expires"])) < datetime.now()): delete_verification_code(cursor, verificationcode) flash("Invalid verification code: code has expired.", "alert-danger") + return loginuri # Code is good! delete_verification_code(cursor, verificationcode) @@ -310,15 +313,29 @@ def list_all_users() -> Response: @users.route("/handle-unverified", methods=["POST"]) def handle_unverified(): """Handle case where user tries to login but is unverified""" - form = request_json() + email = request.args["email"] # TODO: Maybe have a GN2_URI setting here? # or pass the client_id here? + with (db.connection(current_app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + cursor.execute( + "DELETE FROM user_verification_codes WHERE expires <= ?", + (int(datetime.now().timestamp()),)) + cursor.execute( + "SELECT u.user_id, u.email, uvc.* FROM users AS u " + "INNER JOIN user_verification_codes AS uvc " + "ON u.user_id=uvc.user_id " + "WHERE u.email=?", + (email,)) + token_found = bool(cursor.fetchone()) + return render_template( "users/unverified-user.html", - email=form.get("user:email"), + email=email, response_type=request.args["response_type"], client_id=request.args["client_id"], - redirect_uri=request.args["redirect_uri"]) + redirect_uri=request.args["redirect_uri"], + token_found=token_found) @users.route("/send-verification", methods=["POST"]) def send_verification_code(): @@ -350,3 +367,140 @@ def send_verification_code(): }) resp.code = 400 return resp + + +def send_forgot_password_email( + conn, + user: User, + client_id: uuid.UUID, + redirect_uri: str, + response_type: str +): + """Send the 'forgot-password' email.""" + subject="GeneNetwork: Change Your Password" + token = secrets.token_urlsafe(64) + generated = datetime.now() + expiration_minutes = 15 + def __render__(template): + return render_template(template, + subject=subject, + forgot_password_uri=urljoin( + request.url, + url_for("oauth2.users.change_password", + forgot_password_token=token, + client_id=client_id, + redirect_uri=redirect_uri, + response_type=response_type)), + expiration_minutes=expiration_minutes) + + with db.cursor(conn) as cursor: + cursor.execute( + ("INSERT OR REPLACE INTO " + "forgot_password_tokens(user_id, token, generated, expires) " + "VALUES (:user_id, :token, :generated, :expires)"), + { + "user_id": str(user.user_id), + "token": token, + "generated": int(generated.timestamp()), + "expires": int( + (generated + + timedelta( + minutes=expiration_minutes)).timestamp()) + }) + + send_message(smtp_user=current_app.config["SMTP_USER"], + smtp_passwd=current_app.config["SMTP_PASSWORD"], + message=build_email_message( + from_address=current_app.config["EMAIL_ADDRESS"], + to_addresses=(user_address(user),), + subject=subject, + txtmessage=__render__("emails/forgot-password.txt"), + htmlmessage=__render__("emails/forgot-password.html")), + host=current_app.config["SMTP_HOST"], + port=current_app.config["SMTP_PORT"]) + + +@users.route("/forgot-password", methods=["GET", "POST"]) +def forgot_password(): + """Enable user to request password change.""" + if request.method == "GET": + return render_template("users/forgot-password.html", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"]) + + form = request.form + email = form.get("email", "").strip() + if not bool(email): + flash("You MUST provide an email.", "alert-danger") + return redirect(url_for("oauth2.users.forgot_password")) + + with db.connection(current_app.config["AUTH_DB"]) as conn: + user = user_by_email(conn, form["email"]) + if not bool(user): + flash("We could not find an account with that email.", + "alert-danger") + return redirect(url_for("oauth2.users.forgot_password")) + + send_forgot_password_email(conn, + user, + request.args["client_id"], + request.args["redirect_uri"], + request.args["response_type"]) + return render_template("users/forgot-password-token-send-success.html", + email=form["email"]) + + +@users.route("/change-password/<forgot_password_token>", methods=["GET", "POST"]) +def change_password(forgot_password_token): + """Enable user to perform password change.""" + login_page = redirect(url_for("oauth2.auth.authorise", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"])) + with (db.connection(current_app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + cursor.execute("DELETE FROM forgot_password_tokens WHERE expires<=?", + (int(datetime.now().timestamp()),)) + cursor.execute( + "SELECT fpt.*, u.email FROM forgot_password_tokens AS fpt " + "INNER JOIN users AS u ON fpt.user_id=u.user_id WHERE token=?", + (forgot_password_token,)) + token = cursor.fetchone() + if request.method == "GET": + if bool(token): + return render_template( + "users/change-password.html", + email=token["email"], + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"], + forgot_password_token=forgot_password_token) + flash("Invalid Token: We cannot change your password!", + "alert-danger") + return login_page + + password = request.form["password"] + confirm_password = request.form["confirm-password"] + change_password_page = redirect(url_for( + "oauth2.users.change_password", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"], + forgot_password_token=forgot_password_token)) + if bool(password) and bool(confirm_password): + if password == confirm_password: + _user, _hashed_password = set_user_password( + cursor, user_by_email(conn, token["email"]), password) + cursor.execute( + "DELETE FROM forgot_password_tokens WHERE token=?", + (forgot_password_token,)) + flash("Password changed successfully!", "alert-success") + return login_page + + flash("Passwords do not match!", "alert-danger") + return change_password_page + + flash("Both the password and its confirmation MUST be provided!", + "alert-danger") + return change_password_page diff --git a/gn_auth/auth/jwks.py b/gn_auth/auth/jwks.py new file mode 100644 index 0000000..7381000 --- /dev/null +++ b/gn_auth/auth/jwks.py @@ -0,0 +1,86 @@ +"""Utilities dealing with JSON Web Keys (JWK)""" +import os +from pathlib import Path +from typing import Any, Union +from datetime import datetime, timedelta + +from flask import Flask +from authlib.jose import JsonWebKey +from pymonad.either import Left, Right, Either + +def jwks_directory(app: Flask) -> Path: + """Compute the directory where the JWKs are stored.""" + appsecretsdir = Path(app.config["GN_AUTH_SECRETS"]).parent + if appsecretsdir.exists() and appsecretsdir.is_dir(): + jwksdir = Path(appsecretsdir, "jwks/") + if not jwksdir.exists(): + jwksdir.mkdir() + return jwksdir + raise ValueError( + "The `appsecretsdir` value should be a directory that actually exists.") + + +def generate_and_save_private_key( + storagedir: Path, + kty: str = "RSA", + crv_or_size: Union[str, int] = 2048, + options: tuple[tuple[str, Any]] = (("iat", datetime.now().timestamp()),) +) -> JsonWebKey: + """Generate a private key and save to `storagedir`.""" + privatejwk = JsonWebKey.generate_key( + kty, crv_or_size, dict(options), is_private=True) + keyname = f"{privatejwk.thumbprint()}.private.pem" + with open(Path(storagedir, keyname), "wb") as pemfile: + pemfile.write(privatejwk.as_pem(is_private=True)) + + return privatejwk + + +def pem_to_jwk(filepath: Path) -> JsonWebKey: + """Parse a PEM file into a JWK object.""" + with open(filepath, "rb") as pemfile: + return JsonWebKey.import_key(pemfile.read()) + + +def __sorted_jwks_paths__(storagedir: Path) -> tuple[tuple[float, Path], ...]: + """A sorted list of the JWK file paths with their creation timestamps.""" + return tuple(sorted(((os.stat(keypath).st_ctime, keypath) + for keypath in (Path(storagedir, keyfile) + for keyfile in os.listdir(storagedir) + if keyfile.endswith(".pem"))), + key=lambda tpl: tpl[0])) + + +def list_jwks(storagedir: Path) -> tuple[JsonWebKey, ...]: + """ + List all the JWKs in a particular directory in the order they were created. + """ + return tuple(pem_to_jwk(keypath) for ctime,keypath in + __sorted_jwks_paths__(storagedir)) + + +def newest_jwk(storagedir: Path) -> Either: + """ + Return an Either monad with the newest JWK or a message if none exists. + """ + existingkeys = __sorted_jwks_paths__(storagedir) + if len(existingkeys) > 0: + return Right(pem_to_jwk(existingkeys[-1][1])) + return Left("No JWKs exist") + + +def newest_jwk_with_rotation(jwksdir: Path, keyage: int) -> JsonWebKey: + """ + Retrieve the latests JWK, creating a new one if older than `keyage` days. + """ + def newer_than_days(jwkey): + filestat = os.stat(Path( + jwksdir, f"{jwkey.as_dict()['kid']}.private.pem")) + oldesttimeallowed = (datetime.now() - timedelta(days=keyage)) + if filestat.st_ctime < (oldesttimeallowed.timestamp()): + return Left("JWK is too old!") + return jwkey + + return newest_jwk(jwksdir).then(newer_than_days).either( + lambda _errmsg: generate_and_save_private_key(jwksdir), + lambda key: key) diff --git a/gn_auth/auth/requests.py b/gn_auth/auth/requests.py index 6301029..00e9b35 100644 --- a/gn_auth/auth/requests.py +++ b/gn_auth/auth/requests.py @@ -3,4 +3,4 @@ from flask import request def request_json() -> dict: """Retrieve the JSON sent in a request.""" - return request.json or {} + return request.json or dict(request.form) or {} diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py index 17fc94b..6867f38 100644 --- a/gn_auth/auth/views.py +++ b/gn_auth/auth/views.py @@ -11,7 +11,6 @@ from .authorisation.resources.views import resources from .authorisation.privileges.views import privileges from .authorisation.resources.groups.views import groups from .authorisation.resources.system.views import system -from .authorisation.resources.inbredset.views import iset oauth2 = Blueprint("oauth2", __name__) @@ -24,4 +23,3 @@ oauth2.register_blueprint(groups, url_prefix="/group") oauth2.register_blueprint(system, url_prefix="/system") oauth2.register_blueprint(resources, url_prefix="/resource") oauth2.register_blueprint(privileges, url_prefix="/privileges") -oauth2.register_blueprint(iset, url_prefix="/resource/inbredset") |