diff options
99 files changed, 2832 insertions, 885 deletions
diff --git a/.guix/modules/gn-auth.scm b/.guix/modules/gn-auth.scm index 4c51f96..0dab8d9 100644 --- a/.guix/modules/gn-auth.scm +++ b/.guix/modules/gn-auth.scm @@ -34,8 +34,7 @@ #~(modify-phases #$phases (add-before 'build 'pylint (lambda _ - (invoke "pylint" "main.py" "setup.py" "wsgi.py" - "tests" "gn_auth" "scripts"))) + (invoke "pylint" "setup.py" "tests" "gn_auth" "scripts"))) (add-after 'pylint 'mypy (lambda _ (invoke "mypy" "."))))))) diff --git a/MANIFEST.in b/MANIFEST.in index ea5197f..afcd2a8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -global-include static/**/*.js static/**/*.css templates/**/*.html +global-include static/**/*.js static/**/*.css templates/**/*.html templates/**/*.txt global-exclude *~ *.py[cod]
\ No newline at end of file @@ -14,7 +14,7 @@ The recommended way to pass configuration values to the application is via a configuration file passed in via the `GN_AUTH_CONF` environment variable. This variable simply holds the path to the configuration file, e.g. ```sh -export GN_AUTH_CONF="${HOME}/genenetwork/configs/gn_auth_conf.py" +export GN_AUTH_CONF="${HOME}/genenetwork/configs/gn_auth_conf.conf" ``` The settings in the file above will override @@ -146,7 +146,7 @@ $ yoyo new -m "<description of the migration>" ./migrations/auth/ The command will ask whether you want to save the migration configuration, e.g. ```bash -$ yoyo new --config=yoyo.auth.ini -m "testing a new migration" +$ yoyo new --database="sqlite:////tmp/test-auth.db" --migration-table=_yoyo_migration -m "testing a new migration" ./migrations/auth/ Error: could not open editor! Created file ./migrations/auth/20221103_02_HBzwk-testing-a-new-migration.py Save migration configuration to yoyo.ini? @@ -186,7 +186,7 @@ If you have previously initialised the yoyo config file, you can put the databas As a convenience, and to enable the CI/CD to apply the migrations automatically, I have provided a flask cli command that can be run with: ```bash -$ export FLASK_APP=main.py +$ export FLASK_APP=wsgi.py $ flask apply-migrations ``` @@ -205,17 +205,46 @@ following environment variable(s): ### Development +For initial set up, you need a custom configuration file that will contain +custom local_settings. At minimum it can contain: + +```python +# contents for local_settings saved at /absolute/path/to/local_settings_file.conf +SQL_URI = "mysql://user:password@localhost/db_name" # mysql uri +AUTH_DB = "/absolute/path/to/auth.db/" # path to sqlite db file +# path to file containings SECRETS key. +# Note: this path is also used to determine the jwks location +GN_AUTH_SECRETS = "/home/rookie/gn_data/gn2_files/secrets.conf" +``` + +Here's an example `secrets.conf` file: + +```python +SECRET_KEY = "qQIrgiK29kXZU6v8D09y4uw_sk8I4cqgNZniYUrRoUk" +``` + +and you set up the oauth clients using: + +``` +export FLASK_DEBUG=1 AUTHLIB_INSECURE_TRANSPORT=1 OAUTHLIB_INSECURE_TRANSPORT=1 FLASK_APP=gn_auth/wsgi +export GN_AUTH_CONF=/absolute/path/to/local_settings_file.conf +# this sets up a user and client +flask init-dev-clients --client-uri http://localhost:gn2_port_number +``` + To run the application during development: ```sh export FLASK_DEBUG=1 -export FLASK_APP="main.py" +export FLASK_APP="wsgi.py" export AUTHLIB_INSECURE_TRANSPORT=true -export GN_AUTH_CONF="${HOME}/genenetwork/configs/gn_auth_conf.py" +export GN_AUTH_CONF="/absolute/path/to/local_settings_file.conf" flask run --port=8081 ``` -replace the `GN_AUTH_CONF` file with the correct path for your environment. +You can test this by attemptiong to log in to your local GN2 using credentials +defined by the dummy user you set up (see the function `__init_dev_users__` in +`/gn_auth/wsgi.py`). ### Production diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py index b3df070..3b663dc 100644 --- a/gn_auth/__init__.py +++ b/gn_auth/__init__.py @@ -1,6 +1,7 @@ """Application initialisation module.""" import os import sys +import logging from pathlib import Path from typing import Optional, Callable @@ -8,6 +9,7 @@ from flask import Flask from flask_cors import CORS from authlib.jose import JsonWebKey +from gn_auth import hooks from gn_auth.misc_views import misc from gn_auth.auth.views import oauth2 @@ -24,7 +26,7 @@ def check_mandatory_settings(app: Flask) -> None: undefined = tuple( setting for setting in ( "SECRET_KEY", "SQL_URI", "AUTH_DB", "AUTH_MIGRATIONS", - "OAUTH2_SCOPE", "SSL_PRIVATE_KEY", "CLIENTS_SSL_PUBLIC_KEYS_DIR") + "OAUTH2_SCOPES_SUPPORTED") if not ((setting in app.config) and bool(app.config[setting]))) if len(undefined) > 0: raise ConfigurationError( @@ -51,26 +53,38 @@ def load_secrets_conf(app: Flask) -> None: app.config.from_pyfile(secretsfile) -def parse_ssl_keys(app): - """Parse the SSL keys.""" - def __parse_key__(keypath: Path) -> JsonWebKey: - with open(keypath) as _sslkey:# pylint: disable=[unspecified-encoding] - return JsonWebKey.import_key(_sslkey.read()) +def dev_loggers(appl: Flask) -> None: + """Setup the logging handlers.""" + stderr_handler = logging.StreamHandler(stream=sys.stderr) + appl.logger.addHandler(stderr_handler) - key_storage_dir = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"]) - key_storage_dir.mkdir(exist_ok=True) - app.config["SSL_PUBLIC_KEYS"] = { - _key.as_dict()["kid"]: _key for _key in ( - __parse_key__(Path(key_storage_dir).joinpath(key)) - for key in os.listdir(key_storage_dir))} + root_logger = logging.getLogger() + root_logger.addHandler(stderr_handler) + root_logger.setLevel(appl.config["LOGLEVEL"]) - app.config["SSL_PRIVATE_KEY"] = __parse_key__( - Path(app.config["SSL_PRIVATE_KEY"])) -def create_app( - config: Optional[dict] = None, - setup_logging: Callable[[Flask], None] = lambda appl: None -) -> Flask: +def gunicorn_loggers(appl: Flask) -> None: + """Use gunicorn logging handlers for the application.""" + logger = logging.getLogger("gunicorn.error") + appl.logger.handlers = logger.handlers + appl.logger.setLevel(logger.level) + + +def setup_logging(appl: Flask) -> None: + """ + Setup the loggers according to the WSGI server used to run the application. + """ + # https://datatracker.ietf.org/doc/html/draft-coar-cgi-v11-03#section-4.1.17 + # https://wsgi.readthedocs.io/en/latest/proposals-2.0.html#making-some-keys-required + # https://peps.python.org/pep-3333/#id4 + software, *_version_and_comments = os.environ.get( + "SERVER_SOFTWARE", "").split('/') + if bool(software): + gunicorn_loggers(appl) + dev_loggers(appl) + + +def create_app(config: Optional[dict] = None) -> Flask: """Create and return a new flask application.""" app = Flask(__name__) @@ -85,7 +99,6 @@ def create_app( override_settings_with_envvars(app) load_secrets_conf(app) - parse_ssl_keys(app) # ====== END: Setup configuration ====== setup_logging(app) @@ -104,5 +117,6 @@ def create_app( app.register_blueprint(oauth2, url_prefix="/auth") register_error_handlers(app) + hooks.register_hooks(app) return app 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..c802091 100644 --- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py +++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py @@ -1,15 +1,21 @@ """JWT as Authorisation Grant""" import uuid +import time +from typing import Optional from flask import current_app as app +from authlib.jose import jwt +from authlib.common.encoding import to_native from authlib.common.security import generate_token from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant from authlib.oauth2.rfc7523.token import ( JWTBearerTokenGenerator as _JWTBearerTokenGenerator) +from gn_auth.debug import __pk__ from gn_auth.auth.db.sqlite3 import with_db_connection -from gn_auth.auth.authentication.users import user_by_id +from gn_auth.auth.authentication.users import User, user_by_id +from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client class JWTBearerTokenGenerator(_JWTBearerTokenGenerator): @@ -19,23 +25,66 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator): DEFAULT_EXPIRES_IN = 300 - def get_token_data(#pylint: disable=[too-many-arguments] + def get_token_data(#pylint: disable=[too-many-arguments, too-many-positional-arguments] self, grant_type, client, expires_in=None, user=None, scope=None ): """Post process data to prevent JSON serialization problems.""" - tokendata = super().get_token_data( - grant_type, client, expires_in, user, scope) + issued_at = int(time.time()) + tokendata = { + "scope": self.get_allowed_scope(client, scope), + "grant_type": grant_type, + "iat": issued_at, + "client_id": client.get_client_id() + } + if isinstance(expires_in, int) and expires_in > 0: + tokendata["exp"] = issued_at + expires_in + if self.issuer: + tokendata["iss"] = self.issuer + if user: + tokendata["sub"] = self.get_sub_value(user) + return { **{ key: str(value) if key.endswith("_id") else value for key, value in tokendata.items() }, "sub": str(tokendata["sub"]), - "jti": str(uuid.uuid4()) + "jti": str(uuid.uuid4()), + "oauth2_client_id": str(client.client_id) } + def generate(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + self, + grant_type: str, + client: OAuth2Client, + user: Optional[User] = None, + scope: Optional[str] = None, + expires_in: Optional[int] = None + ) -> dict: + """Generate a bearer token for OAuth 2.0 authorization token endpoint. + + :param client: the client that making the request. + :param grant_type: current requested grant_type. + :param user: current authorized user. + :param expires_in: if provided, use this value as expires_in. + :param scope: current requested scope. + :return: Token dict + """ + + token_data = self.get_token_data(grant_type, client, expires_in, user, scope) + access_token = jwt.encode({"alg": self.alg}, token_data, key=self.secret_key, check=False) + token = { + "token_type": "Bearer", + "access_token": to_native(access_token) + } + if expires_in: + token["expires_in"] = expires_in + if scope: + token["scope"] = scope + return token + - def __call__(# pylint: disable=[too-many-arguments] + def __call__(# pylint: disable=[too-many-arguments, too-many-positional-arguments] self, grant_type, client, user=None, scope=None, expires_in=None, include_refresh_token=True ): @@ -74,7 +123,9 @@ 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"]) + keyset = client.jwks() + __pk__("THE KEYSET =======>", keyset.keys) + return keyset.find_by_kid(headers["kid"]) def authenticate_user(self, subject): diff --git a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py index fd6804d..f897d89 100644 --- a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py +++ b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py @@ -34,18 +34,18 @@ class RefreshTokenGrant(grants.RefreshTokenGrant): else Nothing) ).maybe(None, lambda _tok: _tok) - def authenticate_user(self, credential): + def authenticate_user(self, refresh_token): """Check that user is valid for given token.""" with connection(app.config["AUTH_DB"]) as conn: try: - return user_by_id(conn, credential.user.user_id) + return user_by_id(conn, refresh_token.user.user_id) except NotFoundError as _nfe: return None return None - def revoke_old_credential(self, credential): + def revoke_old_credential(self, refresh_token): """Revoke any old refresh token after issuing new refresh token.""" with connection(app.config["AUTH_DB"]) as conn: - if credential.parent_of is not None: - revoke_refresh_token(conn, credential) + if refresh_token.parent_of is not None: + revoke_refresh_token(conn, refresh_token) 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..71769e1 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py @@ -0,0 +1,50 @@ +"""Implement model for JWTBearerToken""" +import uuid +import time +from typing import Optional + +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 +from gn_auth.auth.authentication.oauth2.models.oauth2client import ( + client as fetch_client) + +class JWTBearerToken(_JWTBearerToken): + """Overrides default JWTBearerToken class.""" + + def __init__(self, payload, header, options=None, params=None): + """Initialise the bearer token.""" + # TOD0: Maybe remove this init and make this a dataclass like the way + # OAuth2Client is a dataclass + super().__init__(payload, header, options, params) + self.user = with_db_connection( + lambda conn:user_by_id(conn, uuid.UUID(payload["sub"]))) + self.client = with_db_connection( + lambda conn: fetch_client( + conn, uuid.UUID(payload["oauth2_client_id"]) + ) + ).maybe(None, lambda _client: _client) + + + def check_client(self, client): + """Check that the client is right.""" + return self.client.get_client_id() == client.get_client_id() + + + def get_expires_in(self) -> Optional[int]: + """Return the number of seconds the token is valid for since issue. + + If `None`, the token never expires.""" + if "exp" in self: + return self['exp'] - self['iat'] + return None + + + def is_expired(self): + """Check whether the token is expired. + + If there is no 'exp' member, assume this token will never expire.""" + if "exp" in self: + return self["exp"] < time.time() + return False 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..1639e2e 100644 --- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py +++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py @@ -1,17 +1,19 @@ """OAuth2 Client model.""" import json 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 flask import current_app as app +from requests.exceptions import JSONDecodeError from authlib.jose import KeySet, JsonWebKey from authlib.oauth2.rfc6749 import ClientMixin from pymonad.maybe import Just, Maybe, Nothing +from gn_auth.debug import __pk__ from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.errors import NotFoundError from gn_auth.auth.authentication.users import (User, @@ -57,16 +59,34 @@ 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") + __pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri) + if not bool(jwksuri): + app.logger.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, + timeout=300, + allow_redirects=True).json()["jwks"]]) + except requests.ConnectionError as _connerr: + app.logger.debug( + "Could not connect to provided URI: %s", jwksuri, exc_info=True) + except JSONDecodeError as _jsonerr: + app.logger.debug( + "Could not convert response to JSON", exc_info=True) + except Exception as _exc:# pylint: disable=[broad-except] + app.logger.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 +97,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 +294,25 @@ 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,# pylint: disable=[redefined-outer-name] + 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..8ecf923 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,52 @@ 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))) + self.claims_options = { + 'exp': {'essential': False}, + 'client_id': {'essential': True}, + 'grant_type': {'essential': True}, + } + + 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..8ac5106 100644 --- a/gn_auth/auth/authentication/oauth2/server.py +++ b/gn_auth/auth/authentication/oauth2/server.py @@ -1,23 +1,24 @@ """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 flask import Flask, current_app, request as flask_request +from authlib.jose import KeySet +from authlib.oauth2.rfc6749 import OAuth2Request from authlib.oauth2.rfc6749.errors import InvalidClientError from authlib.integrations.flask_oauth2 import AuthorizationServer +from authlib.integrations.flask_oauth2.requests import FlaskOAuth2Request 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.jwt_bearer_token import JWTBearerToken from .models.oauth2client import client as fetch_client from .models.oauth2token import OAuth2Token, save_token -from .models.jwtrefreshtoken import ( - JWTRefreshToken, - link_child_token, - save_refresh_token, - load_refresh_token) from .grants.password_grant import PasswordGrant from .grants.refresh_token_grant import RefreshTokenGrant @@ -27,7 +28,9 @@ 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 + +_TWO_HOURS_ = 2 * 60 * 60 def create_query_client_func() -> Callable: @@ -45,52 +48,32 @@ 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) -> Callable: """Create the function that saves the token.""" + def __ignore_token__(token, request):# pylint: disable=[unused-argument] + """Ignore the token: i.e. Do not save it.""" + def __save_token__(token, request): - _jwt = jwt.decode(token["access_token"], jwtkey) - _token = token_model( - token_id=uuid.UUID(_jwt["jti"]), - client=request.client, - user=request.user, - **{ - "refresh_token": None, - "revoked": False, - "issued_at": datetime.datetime.now(), - **token - }) with db.connection(current_app.config["AUTH_DB"]) as conn: - save_token(conn, _token) - old_refresh_token = load_refresh_token( + save_token( conn, - request.form.get("refresh_token", "nosuchtoken") - ) - new_refresh_token = JWTRefreshToken( - token=_token.refresh_token, + token_model( + **token, + token_id=uuid.uuid4(), client=request.client, user=request.user, - issued_with=uuid.UUID(_jwt["jti"]), - issued_at=datetime.datetime.fromtimestamp(_jwt["iat"]), - expires=datetime.datetime.fromtimestamp( - old_refresh_token.then( - lambda _tok: _tok.expires.timestamp() - ).maybe((int(_jwt["iat"]) + - RefreshTokenGrant.DEFAULT_EXPIRES_IN), - lambda _expires: _expires)), - scope=_token.get_scope(), + issued_at=datetime.now(), revoked=False, - parent_of=None) - save_refresh_token(conn, new_refresh_token) - old_refresh_token.then(lambda _tok: link_child_token( - conn, _tok.token, new_refresh_token.token)) - - return __save_token__ + expires_in=_TWO_HOURS_)) + return { + OAuth2Token: __save_token__, + JWTBearerToken: __ignore_token__ + }[token_model] def make_jwt_token_generator(app): """Make token generator function.""" - _gen = JWTBearerTokenGenerator(app.config["SSL_PRIVATE_KEY"]) - def __generator__(# pylint: disable=[too-many-arguments] + def __generator__(# pylint: disable=[too-many-arguments, too-many-positional-arguments] grant_type, client, user=None, @@ -98,19 +81,42 @@ 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( + secret_key=newest_jwk_with_rotation( + jwks_directory(app), + int(app.config["JWKS_ROTATION_AGE_DAYS"])), + issuer=flask_request.host_url, + alg="RS256").__call__( + grant_type=grant_type, + client=client, + user=user, + scope=scope, + expires_in=expires_in, + include_refresh_token=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.""" + match flask_request.headers.get("Content-Type"): + case "application/json": + req = OAuth2Request(flask_request.method, + flask_request.url, + flask_request.get_json(), + flask_request.headers) + case _: + req = FlaskOAuth2Request(flask_request) + + return req + + 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 +139,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(JWTBearerToken)) 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..0e2c4eb 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): @@ -65,14 +77,15 @@ def authorise(): try: email = validate_email( form.get("user:email"), check_deliverability=False) - user = user_by_email(conn, email["email"]) + user = user_by_email(conn, email["email"]) # type: ignore if valid_login(conn, user, form.get("user:password", "")): if not user.verified: return redirect( 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/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py index bdab8fa..ddb0add 100644 --- a/gn_auth/auth/authorisation/data/genotypes.py +++ b/gn_auth/auth/authorisation/data/genotypes.py @@ -3,9 +3,9 @@ import uuid from dataclasses import asdict from typing import Iterable +from gn_libs import mysqldb as gn3db from MySQLdb.cursors import DictCursor -from gn_auth.auth.db import mariadb as gn3db from gn_auth.auth.db import sqlite3 as authdb from gn_auth.auth.authorisation.checks import authorised_p @@ -22,8 +22,8 @@ def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]: "You do not have sufficient privileges to link data to (a) " "group(s)."), oauth2_scope="profile group resource") -def ungrouped_genotype_data(# pylint: disable=[too-many-arguments] - authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection, +def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + authconn: authdb.DbConnection, gn3conn: gn3db.Connection, search_query: str, selected: tuple[dict, ...] = tuple(), limit: int = 10000, offset: int = 0) -> tuple[ dict, ...]: diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py index 60470a7..0cc644e 100644 --- a/gn_auth/auth/authorisation/data/mrna.py +++ b/gn_auth/auth/authorisation/data/mrna.py @@ -2,10 +2,11 @@ import uuid from dataclasses import asdict from typing import Iterable + +from gn_libs import mysqldb as gn3db from MySQLdb.cursors import DictCursor from gn_auth.auth.db import sqlite3 as authdb -from gn_auth.auth.db import mariadb as gn3db from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.authorisation.resources.groups.models import Group @@ -21,8 +22,8 @@ def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]: "You do not have sufficient privileges to link data to (a) " "group(s)."), oauth2_scope="profile group resource") -def ungrouped_mrna_data(# pylint: disable=[too-many-arguments] - authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection, +def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + authconn: authdb.DbConnection, gn3conn: gn3db.Connection, search_query: str, selected: tuple[dict, ...] = tuple(), limit: int = 10000, offset: int = 0) -> tuple[ dict, ...]: diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py index 0a76237..3e45af3 100644 --- a/gn_auth/auth/authorisation/data/phenotypes.py +++ b/gn_auth/auth/authorisation/data/phenotypes.py @@ -3,16 +3,20 @@ import uuid from dataclasses import asdict from typing import Any, Iterable +from gn_libs import mysqldb as gn3db from MySQLdb.cursors import DictCursor from gn_auth.auth.db import sqlite3 as authdb -from gn_auth.auth.db import mariadb as gn3db +from gn_auth.auth.errors import AuthorisationError from gn_auth.auth.authorisation.checks import authorised_p -from gn_auth.auth.authorisation.resources.groups.models import Group +from gn_auth.auth.authorisation.resources.system.models import system_resource +from gn_auth.auth.authorisation.resources.groups.models import Group, group_resource + +from gn_auth.auth.authorisation.resources.checks import authorised_for2 def linked_phenotype_data( - authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection, + authconn: authdb.DbConnection, gn3conn: gn3db.Connection, species: str = "") -> Iterable[dict[str, Any]]: """Retrieve phenotype data linked to user groups.""" authkeys = ("SpeciesId", "InbredSetId", "PublishFreezeId", "PublishXRefId") @@ -53,7 +57,7 @@ def linked_phenotype_data( "group(s)."), oauth2_scope="profile group resource") def ungrouped_phenotype_data( - authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection): + authconn: authdb.DbConnection, gn3conn: gn3db.Connection): """Retrieve phenotype data that is not linked to any user group.""" with gn3conn.cursor() as cursor: params = tuple( @@ -83,7 +87,7 @@ def ungrouped_phenotype_data( return tuple() -def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[dict, ...]: +def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]: """An internal utility function. Don't use outside of this module.""" if len(params) < 1: return tuple() @@ -110,21 +114,33 @@ def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[d for itm in sublist)) return cursor.fetchall() -@authorised_p(("system:data:link-to-group",), - error_description=( - "You do not have sufficient privileges to link data to (a) " - "group(s)."), - oauth2_scope="profile group resource") + def link_phenotype_data( - authconn:authdb.DbConnection, gn3conn: gn3db.DbConnection, group: Group, - traits: tuple[dict, ...]) -> dict: + authconn: authdb.DbConnection, + user, + group: Group, + traits: tuple[dict, ...] +) -> dict: """Link phenotype traits to a user group.""" + if not (authorised_for2(authconn, + user, + system_resource(authconn), + ("system:data:link-to-group",)) + or + authorised_for2(authconn, + user, + group_resource(authconn, group.group_id), + ("group:data:link-to-group",)) + ): + raise AuthorisationError( + "You do not have sufficient privileges to link data to group " + f"'{group.group_name}'.") with authdb.cursor(authconn) as cursor: params = tuple({ "data_link_id": str(uuid.uuid4()), "group_id": str(group.group_id), **item - } for item in __traits__(gn3conn, traits)) + } for item in traits) cursor.executemany( "INSERT INTO linked_phenotype_data " "VALUES (" diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py index 7ed69e3..9123949 100644 --- a/gn_auth/auth/authorisation/data/views.py +++ b/gn_auth/auth/authorisation/data/views.py @@ -11,6 +11,9 @@ from MySQLdb.cursors import DictCursor from authlib.integrations.flask_oauth2.errors import _HTTPException from flask import request, jsonify, Response, Blueprint, current_app as app + +from gn_libs import mysqldb as gn3db + from gn_auth import jobs from gn_auth.commands import run_async_cmd @@ -19,7 +22,6 @@ from gn_auth.auth.errors import InvalidData, NotFoundError from gn_auth.auth.authorisation.resources.groups.models import group_by_id from ...db import sqlite3 as db -from ...db import mariadb as gn3db from ...db.sqlite3 import with_db_connection from ..checks import require_json @@ -33,8 +35,8 @@ from ..resources.models import ( from ...authentication.users import User from ...authentication.oauth2.resource_server import require_oauth -from ..data.phenotypes import link_phenotype_data from ..data.mrna import link_mrna_data, ungrouped_mrna_data +from ..data.phenotypes import link_phenotype_data, pheno_traits_from_db from ..data.genotypes import link_genotype_data, ungrouped_genotype_data data = Blueprint("data", __name__) @@ -187,7 +189,7 @@ def __search_mrna__(): def __request_key__(key: str, default: Any = ""): if bool(request_json()): return request_json().get(#type: ignore[union-attr] - key, request.args.get(key, request_json().get(key, default))) + key, request.args.get(key, default)) return request.args.get(key, request_json().get(key, default)) def __request_key_list__(key: str, default: tuple[Any, ...] = tuple()): @@ -310,6 +312,7 @@ def link_mrna() -> Response: partial(__link__, **__values__(request_json())))) @data.route("/link/phenotype", methods=["POST"]) +@require_oauth("profile group resource") def link_phenotype() -> Response: """Link phenotype data to group.""" def __values__(form): @@ -325,14 +328,27 @@ def link_phenotype() -> Response: raise InvalidData("Expected at least one dataset to be provided.") return { "group_id": uuid.UUID(form["group_id"]), - "traits": form["selected"] + "traits": form["selected"], + "using_raw_ids": bool(form.get("using-raw-ids") == "on") } - with gn3db.database_connection(app.config["SQL_URI"]) as gn3conn: - def __link__(conn: db.DbConnection, group_id: uuid.UUID, - traits: tuple[dict, ...]) -> dict: - return link_phenotype_data( - conn, gn3conn, group_by_id(conn, group_id), traits) + with (require_oauth.acquire("profile group resource") as token, + gn3db.database_connection(app.config["SQL_URI"]) as gn3conn): + def __link__( + conn: db.DbConnection, + group_id: uuid.UUID, + traits: tuple[dict, ...], + using_raw_ids: bool = False + ) -> dict: + if using_raw_ids: + return link_phenotype_data(conn, + token.user, + group_by_id(conn, group_id), + traits) + return link_phenotype_data(conn, + token.user, + group_by_id(conn, group_id), + pheno_traits_from_db(gn3conn, traits)) return jsonify(with_db_connection( partial(__link__, **__values__(request_json())))) 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/checks.py b/gn_auth/auth/authorisation/resources/checks.py index d8e3a9f..5484dbf 100644 --- a/gn_auth/auth/authorisation/resources/checks.py +++ b/gn_auth/auth/authorisation/resources/checks.py @@ -3,9 +3,13 @@ from uuid import UUID from functools import reduce from typing import Sequence +from .base import Resource + from ...db import sqlite3 as db from ...authentication.users import User +from ..privileges.models import db_row_to_privilege + def __organise_privileges_by_resource_id__(rows): def __organise__(privs, row): resource_id = UUID(row["resource_id"]) @@ -16,6 +20,7 @@ def __organise_privileges_by_resource_id__(rows): } return reduce(__organise__, rows, {}) + def authorised_for(conn: db.DbConnection, user: User, privileges: tuple[str, ...], @@ -45,3 +50,35 @@ def authorised_for(conn: db.DbConnection, resource_id: resource_id in authorised for resource_id in resource_ids } + + +def authorised_for2( + conn: db.DbConnection, + user: User, + resource: Resource, + privileges: tuple[str, ...] +) -> bool: + """ + Check that `user` has **ALL** the specified privileges for the resource. + """ + with db.cursor(conn) as cursor: + _query = ( + "SELECT resources.resource_id, user_roles.user_id, roles.role_id, " + "privileges.* " + "FROM resources INNER JOIN user_roles " + "ON resources.resource_id=user_roles.resource_id " + "INNER JOIN roles ON user_roles.role_id=roles.role_id " + "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id " + "INNER JOIN privileges " + "ON role_privileges.privilege_id=privileges.privilege_id " + "WHERE resources.resource_id=? " + "AND user_roles.user_id=?") + cursor.execute( + _query, + (str(resource.resource_id), str(user.user_id))) + _db_privileges = tuple( + db_row_to_privilege(row) for row in cursor.fetchall()) + + str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges) + return all((requested_privilege in str_privileges) + for requested_privilege in privileges) 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..762ee7c 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( @@ -28,14 +27,15 @@ def resource_data( def link_data_to_resource( conn: db.DbConnection, resource: Resource, - data_link_id: uuid.UUID) -> dict: - """Link Genotype data with a resource.""" + data_link_ids: tuple[uuid.UUID, ...] +) -> tuple[dict, ...]: + """Link Genotype data with a resource using the GUI.""" with db.cursor(conn) as cursor: - params = { + params = tuple({ "resource_id": str(resource.resource_id), "data_link_id": str(data_link_id) - } - cursor.execute( + } for data_link_id in data_link_ids) + cursor.executemany( "INSERT INTO genotype_resources VALUES" "(:resource_id, :data_link_id)", params) @@ -67,3 +67,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, too-many-positional-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/data.py b/gn_auth/auth/authorisation/resources/groups/data.py index 702955d..ad0dfba 100644 --- a/gn_auth/auth/authorisation/resources/groups/data.py +++ b/gn_auth/auth/authorisation/resources/groups/data.py @@ -1,7 +1,7 @@ """Handles the resource objects' data.""" +from gn_libs import mysqldb as gn3db from MySQLdb.cursors import DictCursor -from gn_auth.auth.db import mariadb as gn3db from gn_auth.auth.db import sqlite3 as authdb from gn_auth.auth.errors import NotFoundError @@ -9,7 +9,7 @@ from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.authorisation.resources.groups import Group def __fetch_mrna_data_by_ids__( - conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[ + conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[ dict, ...]: """Fetch mRNA Assay data by ID.""" with conn.cursor(DictCursor) as cursor: @@ -27,7 +27,7 @@ def __fetch_mrna_data_by_ids__( raise NotFoundError("Could not find mRNA Assay data with the given ID.") def __fetch_geno_data_by_ids__( - conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[ + conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[ dict, ...]: """Fetch genotype data by ID.""" with conn.cursor(DictCursor) as cursor: @@ -45,7 +45,7 @@ def __fetch_geno_data_by_ids__( raise NotFoundError("Could not find Genotype data with the given ID.") def __fetch_pheno_data_by_ids__( - conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[ + conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[ dict, ...]: """Fetch phenotype data by ID.""" with conn.cursor(DictCursor) as cursor: @@ -67,7 +67,7 @@ def __fetch_pheno_data_by_ids__( "Could not find Phenotype/Publish data with the given IDs.") def __fetch_data_by_id( - conn: gn3db.DbConnection, dataset_type: str, + conn: gn3db.Connection, dataset_type: str, dataset_ids: tuple[str, ...]) -> tuple[dict, ...]: """Fetch data from MySQL by IDs.""" fetch_fns = { @@ -83,7 +83,7 @@ def __fetch_data_by_id( "group(s)."), oauth2_scope="profile group resource") def link_data_to_group( - authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection, + authconn: authdb.DbConnection, gn3conn: gn3db.Connection, dataset_type: str, dataset_ids: tuple[str, ...], group: Group) -> tuple[ dict, ...]: """Link the given data to the specified group.""" diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py index ee77654..2df5f04 100644 --- a/gn_auth/auth/authorisation/resources/groups/models.py +++ b/gn_auth/auth/authorisation/resources/groups/models.py @@ -5,16 +5,21 @@ 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 +from pymonad.either import Left, Right, Either +from pymonad.tools import monad_from_none_or_value from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.authentication.users import User, user_by_id from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.authorisation.privileges import Privilege -from gn_auth.auth.authorisation.resources.base import Resource from gn_auth.auth.authorisation.resources.errors import MissingGroupError +from gn_auth.auth.authorisation.resources.base import ( + Resource, + resource_from_dbrow) from gn_auth.auth.errors import ( NotFoundError, AuthorisationError, InconsistencyError) from gn_auth.auth.authorisation.roles.models import ( @@ -63,6 +68,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 = ( @@ -110,7 +122,7 @@ def create_group( cursor, group_name, ( {"group_description": group_description} if group_description else {})) - group_resource = { + _group_resource = { "group_id": str(new_group.group_id), "resource_id": str(uuid4()), "resource_name": group_name, @@ -123,17 +135,17 @@ def create_group( cursor.execute( "INSERT INTO resources VALUES " "(:resource_id, :resource_name, :resource_category_id, :public)", - group_resource) + _group_resource) cursor.execute( "INSERT INTO group_resources(resource_id, group_id) " "VALUES(:resource_id, :group_id)", - group_resource) + _group_resource) add_user_to_group(cursor, new_group, group_leader) revoke_user_role_by_name(cursor, group_leader, "group-creator") assign_user_role_by_name( cursor, group_leader, - UUID(str(group_resource["resource_id"])), + UUID(str(_group_resource["resource_id"])), "group-leader") return new_group @@ -489,3 +501,44 @@ def add_resources_to_group(conn: db.DbConnection, "group_id": str(group.group_id), "resource_id": str(rsc.resource_id) } for rsc in resources)) + + +def admin_group(conn: db.DbConnection) -> Either: + """Return a group where at least one system admin is a member.""" + query = ( + "SELECT DISTINCT g.group_id, g.group_name, g.group_metadata " + "FROM roles AS r INNER JOIN user_roles AS ur ON r.role_id=ur.role_id " + "INNER JOIN group_users AS gu ON ur.user_id=gu.user_id " + "INNER JOIN groups AS g ON gu.group_id=g.group_id " + "WHERE role_name='system-administrator'") + with db.cursor(conn) as cursor: + cursor.execute(query) + return monad_from_none_or_value( + Left("There is no group of which the system admininstrator is a " + "member."), + lambda row: Right(Group( + UUID(row["group_id"]), + row["group_name"], + json.loads(row["group_metadata"]))), + cursor.fetchone()) + + +def group_resource(conn: db.DbConnection, group_id: UUID) -> Resource: + """Retrieve the system resource.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT group_resources.group_id, resource_categories.*, " + "resources.resource_id, resources.resource_name, resources.public " + "FROM group_resources INNER JOIN resources " + "ON group_resources.resource_id=resources.resource_id " + "INNER JOIN resource_categories " + "ON resources.resource_category_id=resource_categories.resource_category_id " + "WHERE group_resources.group_id=? " + "AND resource_categories.resource_category_key='group'", + (str(group_id),)) + row = cursor.fetchone() + if row: + return resource_from_dbrow(row) + + raise NotFoundError("Could not find a resource for group with ID " + f"{group_id}") diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py index 401be00..746e23c 100644 --- a/gn_auth/auth/authorisation/resources/groups/views.py +++ b/gn_auth/auth/authorisation/resources/groups/views.py @@ -9,10 +9,10 @@ from dataclasses import asdict from MySQLdb.cursors import DictCursor from flask import jsonify, Response, Blueprint, current_app -from gn_auth.auth.requests import request_json +from gn_libs import mysqldb as gn3db +from gn_auth.auth.requests import request_json from gn_auth.auth.db import sqlite3 as db -from gn_auth.auth.db import mariadb as gn3db from gn_auth.auth.db.sqlite3 import with_db_connection from gn_auth.auth.authorisation.privileges import privileges_by_ids @@ -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: @@ -167,7 +169,7 @@ def unlinked_genotype_data( return tuple(dict(row) for row in cursor.fetchall()) def unlinked_phenotype_data( - authconn: db.DbConnection, gn3conn: gn3db.DbConnection, + authconn: db.DbConnection, gn3conn: gn3db.Connection, group: Group) -> tuple[dict, ...]: """ Retrieve all phenotype data linked to a group but not linked to any @@ -233,7 +235,7 @@ def unlinked_data(resource_type: str) -> Response: if resource_type in ("system", "group"): return jsonify(tuple()) - if resource_type not in ("all", "mrna", "genotype", "phenotype"): + if resource_type not in ("all", "mrna", "genotype", "phenotype", "inbredset-group"): raise AuthorisationError(f"Invalid resource type {resource_type}") with require_oauth.acquire("profile group resource") as the_token: @@ -251,7 +253,8 @@ def unlinked_data(resource_type: str) -> Response: "genotype": unlinked_genotype_data, "phenotype": lambda conn, grp: partial( unlinked_phenotype_data, gn3conn=gn3conn)( - authconn=conn, group=grp) + authconn=conn, group=grp), + "inbredset-group": lambda authconn, ugroup: [] # Still need to implement this } return jsonify(tuple( dict(row) for row in unlinked_fns[resource_type]( 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..64d41e3 --- /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, too-many-positional-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..40dd38d 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, admin_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,76 @@ 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}) + + def __default_group_if_none__(group) -> Either: + if group.is_nothing(): + return admin_group(conn) + return Right(group.value) + + return __default_group_if_none__( + 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 c7c8352..e538a87 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, 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, too-many-positional-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 " @@ -224,8 +207,12 @@ def resource_by_id( raise NotFoundError(f"Could not find a resource with id '{resource_id}'") def link_data_to_resource( - conn: db.DbConnection, user: User, resource_id: UUID, dataset_type: str, - data_link_id: UUID) -> dict: + conn: db.DbConnection, + user: User, + resource_id: UUID, + dataset_type: str, + data_link_ids: tuple[UUID, ...] +) -> tuple[dict, ...]: """Link data to resource.""" if not authorised_for( conn, user, ("group:resource:edit-resource",), @@ -240,7 +227,7 @@ def link_data_to_resource( "mrna": mrna_link_data_to_resource, "genotype": genotype_link_data_to_resource, "phenotype": phenotype_link_data_to_resource, - }[dataset_type.lower()](conn, resource, data_link_id) + }[dataset_type.lower()](conn, resource, data_link_ids) def unlink_data_from_resource( conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID): diff --git a/gn_auth/auth/authorisation/resources/mrna.py b/gn_auth/auth/authorisation/resources/mrna.py index 7fce227..66f8824 100644 --- a/gn_auth/auth/authorisation/resources/mrna.py +++ b/gn_auth/auth/authorisation/resources/mrna.py @@ -26,14 +26,15 @@ def resource_data(cursor: db.DbCursor, def link_data_to_resource( conn: db.DbConnection, resource: Resource, - data_link_id: uuid.UUID) -> dict: + data_link_ids: tuple[uuid.UUID, ...] +) -> tuple[dict, ...]: """Link mRNA Assay data with a resource.""" with db.cursor(conn) as cursor: - params = { + params = tuple({ "resource_id": str(resource.resource_id), "data_link_id": str(data_link_id) - } - cursor.execute( + } for data_link_id in data_link_ids) + cursor.executemany( "INSERT INTO mrna_resources VALUES" "(:resource_id, :data_link_id)", params) 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..0ef91ab --- /dev/null +++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py @@ -0,0 +1,143 @@ +"""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_ids: tuple[uuid.UUID, ...] +) -> tuple[dict, ...]: + """Link Phenotype data with a resource.""" + with db.cursor(conn) as cursor: + params = tuple({ + "resource_id": str(resource.resource_id), + "data_link_id": str(data_link_id) + } for data_link_id in data_link_ids) + cursor.executemany( + "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..c0a5e81 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/phenotypes/views.py @@ -0,0 +1,77 @@ +"""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"]) +@require_oauth("profile group resource") +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"]) +@require_oauth("profile group resource") +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/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py index 7c176aa..303b0ac 100644 --- a/gn_auth/auth/authorisation/resources/system/models.py +++ b/gn_auth/auth/authorisation/resources/system/models.py @@ -4,11 +4,15 @@ from functools import reduce from typing import Sequence from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.errors import NotFoundError from gn_auth.auth.authentication.users import User from gn_auth.auth.authorisation.roles import Role from gn_auth.auth.authorisation.privileges import Privilege +from gn_auth.auth.authorisation.resources.base import ( + Resource, + resource_from_dbrow) def __organise_privileges__(acc, row): role_id = UUID(row["role_id"]) @@ -24,6 +28,7 @@ def __organise_privileges__(acc, row): (Privilege(row["privilege_id"], row["privilege_description"]),))) } + def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]: """ Retrieve all roles assigned to the `user` that act on `system` resources. @@ -45,3 +50,19 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]: return tuple(reduce( __organise_privileges__, cursor.fetchall(), {}).values()) return tuple() + + +def system_resource(conn: db.DbConnection) -> Resource: + """Retrieve the system resource.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT resource_categories.*, resources.resource_id, " + "resources.resource_name, resources.public " + "FROM resource_categories INNER JOIN resources " + "ON resource_categories.resource_category_id=resources.resource_category_id " + "WHERE resource_categories.resource_category_key='system'") + row = cursor.fetchone() + if row: + return resource_from_dbrow(row) + + raise NotFoundError("Could not find a system resource!") diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py index 2eda72b..0a68927 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: @@ -122,7 +137,7 @@ def view_resource_data(resource_id: UUID) -> Response: with require_oauth.acquire("profile group resource") as the_token: db_uri = app.config["AUTH_DB"] count_per_page = __safe_get_requests_count__("count_per_page") - offset = (__safe_get_requests_page__("page") - 1) + offset = __safe_get_requests_page__("page") - 1 with db.connection(db_uri) as conn: resource = resource_by_id(conn, the_token.user, resource_id) return jsonify(resource_data( @@ -138,7 +153,7 @@ def link_data(): try: form = request_json() assert "resource_id" in form, "Resource ID not provided." - assert "data_link_id" in form, "Data Link ID not provided." + assert "data_link_ids" in form, "Data Link IDs not provided." assert "dataset_type" in form, "Dataset type not specified" assert form["dataset_type"].lower() in ( "mrna", "genotype", "phenotype"), "Invalid dataset type provided." @@ -146,8 +161,11 @@ def link_data(): with require_oauth.acquire("profile group resource") as the_token: def __link__(conn: db.DbConnection): return link_data_to_resource( - conn, the_token.user, UUID(form["resource_id"]), - form["dataset_type"], UUID(form["data_link_id"])) + conn, + the_token.user, + UUID(form["resource_id"]), + form["dataset_type"], + tuple(UUID(dlinkid) for dlinkid in form["data_link_ids"])) return jsonify(with_db_connection(__link__)) except AssertionError as aserr: @@ -265,7 +283,7 @@ def assign_role_to_user(resource_id: UUID) -> Response: user = user_by_email(conn, user_email) return assign_resource_user( conn, resource, user, - role_by_id(conn, UUID(role_id))) + role_by_id(conn, UUID(role_id)))# type: ignore[arg-type] except AssertionError as aserr: raise AuthorisationError(aserr.args[0]) from aserr @@ -292,7 +310,7 @@ def unassign_role_to_user(resource_id: UUID) -> Response: resource = resource_by_id(conn, _token.user, resource_id) return unassign_resource_user( conn, resource, user_by_id(conn, UUID(user_id)), - role_by_id(conn, UUID(role_id))) + role_by_id(conn, UUID(role_id)))# type: ignore[arg-type] except AssertionError as aserr: raise AuthorisationError(aserr.args[0]) from aserr @@ -396,9 +414,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__)) @@ -439,6 +466,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 @@ -491,7 +526,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..6faeaca 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: @@ -261,7 +271,7 @@ def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]: _roles = db_rows_to_roles(results) if len(_roles) > 1: - raise Exception("Data corruption: Expected a single role.") + raise Exception("Data corruption: Expected a single role.")# pylint: disable=[broad-exception-raised] return _roles[0] diff --git a/gn_auth/auth/authorisation/users/admin/ui.py b/gn_auth/auth/authorisation/users/admin/ui.py index 64e79a0..43ca0a2 100644 --- a/gn_auth/auth/authorisation/users/admin/ui.py +++ b/gn_auth/auth/authorisation/users/admin/ui.py @@ -1,6 +1,6 @@ """UI utilities for the auth system.""" from functools import wraps -from flask import flash, url_for, redirect +from flask import flash, request, url_for, redirect from gn_auth.session import logged_in, session_user, clear_session_info from gn_auth.auth.authorisation.resources.system.models import ( @@ -24,5 +24,5 @@ def is_admin(func): flash("Expected a system administrator.", "alert-danger") flash("You have been logged out of the system.", "alert-info") clear_session_info() - return redirect(url_for("oauth2.admin.login")) + return redirect(url_for("oauth2.admin.login", **dict(request.args))) return __admin__ diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py index 8ca1e51..9bc1c36 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,8 +96,9 @@ def login(): session.update_session_info( user=asdict(user), expires=( - datetime.now(tz=timezone.utc) + timedelta(minutes=10))) - return redirect(url_for(next_uri)) + datetime.now(tz=timezone.utc) + timedelta(minutes=int( + app.config.get("SESSION_EXPIRY_MINUTES", 10))))) + return redirect(url_for(next_uri, **dict(request.args))) raise NotFoundError(error_message) except NotFoundError as _nfe: flash(error_message, "alert-danger") @@ -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) @@ -193,7 +197,7 @@ def register_client(): if request.method == "GET": return render_template( "admin/register-client.html", - scope=app.config["OAUTH2_SCOPE"], + scope=app.config["OAUTH2_SCOPES_SUPPORTED"], users=with_db_connection(__list_users__), granttypes=_FORM_GRANT_TYPES_, current_user=session.session_user()) @@ -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"]))) @@ -257,111 +262,9 @@ def view_client(client_id: uuid.UUID): return render_template( "admin/view-oauth2-client.html", client=with_db_connection(partial(oauth2_client, client_id=client_id)), - scope=app.config["OAUTH2_SCOPE"], + scope=app.config["OAUTH2_SCOPES_SUPPORTED"], 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,37 @@ 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): + """Enable changing of a client's secret.""" + 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..63443ef 100644 --- a/gn_auth/auth/authorisation/users/collections/models.py +++ b/gn_auth/auth/authorisation/users/collections/models.py @@ -33,7 +33,7 @@ def __valid_email__(email:str) -> bool: def __toggle_boolean_field__( rconn: Redis, email: str, field: str): """Toggle the valuen of a boolean field""" - mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") + mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") # type: ignore if bool(mig_dict): rconn.hset("migratable-accounts", email, json.dumps({**mig_dict, field: not mig_dict.get(field, True)})) @@ -52,7 +52,7 @@ def __build_email_uuid_bridge__(rconn: Redis): "resources_migrated": False } for account in ( acct for acct in - (json.loads(usr) for usr in rconn.hgetall("users").values()) + (json.loads(usr) for usr in rconn.hgetall("users").values()) # type: ignore if (bool(acct.get("email_address", False)) and __valid_email__(acct["email_address"]))) } @@ -66,7 +66,7 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict: accounts = rconn.hgetall("migratable-accounts") if accounts: return { - key: json.loads(value) for key, value in accounts.items() + key: json.loads(value) for key, value in accounts.items() # type: ignore } return __build_email_uuid_bridge__(rconn) @@ -91,13 +91,13 @@ def __retrieve_old_user_collections__(rconn: Redis, old_user_id: UUID) -> tuple: """Retrieve any old collections relating to the user.""" return tuple(parse_collection(coll) for coll in json.loads(rconn.hget( - __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]")) + __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]")) # type: ignore def user_collections(rconn: Redis, user: User) -> tuple[dict, ...]: """Retrieve current user collections.""" collections = tuple(parse_collection(coll) for coll in json.loads( rconn.hget(REDIS_COLLECTIONS_KEY, str(user.user_id)) or - "[]")) + "[]")) # type: ignore old_accounts = __retrieve_old_accounts__(rconn) if (user.email in old_accounts and not old_accounts[user.email]["collections-migrated"]): @@ -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..5c11f34 100644 --- a/gn_auth/auth/authorisation/users/masquerade/models.py +++ b/gn_auth/auth/authorisation/users/masquerade/models.py @@ -1,20 +1,26 @@ """Functions for handling masquerade.""" -from uuid import uuid4 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 gn_auth.auth.authentication.oauth2.grants.refresh_token_grant import ( + RefreshTokenGrant) +from gn_auth.auth.authentication.oauth2.models.jwtrefreshtoken import ( + JWTRefreshToken, + save_refresh_token) + from ...roles.models import user_roles from ....db import sqlite3 as db from ....authentication.users import User -from ....authentication.oauth2.models.oauth2token import ( - OAuth2Token, save_token) +from ....authentication.oauth2.models.oauth2token import OAuth2Token -__FIVE_HOURS__ = (60 * 60 * 5) +__FIVE_HOURS__ = 60 * 60 * 5 def can_masquerade(func): """Security decorator.""" @@ -31,9 +37,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.") @@ -46,22 +56,30 @@ def masquerade_as( original_token: OAuth2Token, masqueradee: User) -> OAuth2Token: """Get a token that enables `masquerader` to act as `masqueradee`.""" - token_details = app.config["OAUTH2_SERVER"].generate_token( + scope = original_token.get_scope().replace( + # Do not allow more than one level of masquerading + "masquerade", "").strip() + new_token = app.config["OAUTH2_SERVER"].generate_token( client=original_token.client, - grant_type="authorization_code", + grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer", user=masqueradee, - expires_in=__FIVE_HOURS__, - include_refresh_token=True) - new_token = OAuth2Token( - token_id=uuid4(), + expires_in=original_token.get_expires_in(), + include_refresh_token=True, + scope=scope) + _jwt = jwt.decode( + new_token["access_token"], + newest_jwk_with_rotation( + jwks_directory(app), + int(app.config["JWKS_ROTATION_AGE_DAYS"]))) + save_refresh_token(conn, JWTRefreshToken( + token=new_token["refresh_token"], client=original_token.client, - token_type=token_details["token_type"], - access_token=token_details["access_token"], - refresh_token=token_details.get("refresh_token"), - scope=original_token.scope, + user=masqueradee, + issued_with=_jwt["jti"], + issued_at=datetime.fromtimestamp(_jwt["iat"]), + expires=datetime.fromtimestamp( + int(_jwt["iat"]) + RefreshTokenGrant.DEFAULT_EXPIRES_IN), + scope=scope, revoked=False, - issued_at=datetime.now(), - expires_in=token_details["expires_in"], - user=masqueradee) - save_token(conn, new_token) + parent_of=None)) return new_token diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py index 276859a..8b897f2 100644 --- a/gn_auth/auth/authorisation/users/masquerade/views.py +++ b/gn_auth/auth/authorisation/users/masquerade/views.py @@ -28,22 +28,17 @@ def masquerade() -> Response: masq_user = with_db_connection(partial( user_by_id, user_id=masqueradee_id)) + def __masq__(conn): new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user) return new_token - def __dump_token__(tok): - return { - key: value for key, value in (tok._asdict().items()) - if key in ("access_token", "refresh_token", "expires_in", - "token_type") - } + return jsonify({ "original": { - "user": token.user._asdict(), - "token": __dump_token__(token) + "user": asdict(token.user) }, "masquerade_as": { "user": asdict(masq_user), - "token": __dump_token__(with_db_connection(__masq__)) + "token": with_db_connection(__masq__) } }) diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py index bde2e33..ef3ce7f 100644 --- a/gn_auth/auth/authorisation/users/models.py +++ b/gn_auth/auth/authorisation/users/models.py @@ -1,6 +1,8 @@ """Functions for acting on users.""" import uuid +from typing import Union from functools import reduce +from datetime import datetime, timedelta from ..roles.models import Role from ..checks import authorised_p @@ -9,14 +11,72 @@ from ..privileges import Privilege from ...db import sqlite3 as db from ...authentication.users import User + +def __process_age_clause__(age_desc: str) -> tuple[str, int]: + """Process the age clause and parameter for 'LIST USERS' query.""" + _today = datetime.now() + _clause = "created" + _parts = age_desc.split(" ") + _multipliers = { + # Temporary hack before dateutil module can make it to our deployment. + "days": 1, + "months": 30, + "years": 365 + } + assert len(_parts) in (3, 4), "Invalid age descriptor!" + + _param = int(( + _today - timedelta(**{"days": int(_parts[-2]) * _multipliers[_parts[-1]]}) + ).timestamp()) + + match _parts[0]: + case "older": + return "created < :created", _param + case "younger": + return "created > :created", _param + case "exactly": + return "created = :created", _param + case _: + raise Exception("Invalid age descriptor.") + + +def __list_user_clauses_and_params__(**kwargs) -> tuple[list[str], dict[str, Union[int, str]]]: + """Process the WHERE clauses, and params for the 'LIST USERS' query.""" + clauses = [] + params = {} + if bool(kwargs.get("email", "").strip()): + clauses = clauses + ["email LIKE :email"] + params["email"] = f'%{kwargs["email"].strip()}%' + + if bool(kwargs.get("name", "").strip()): + clauses = clauses + ["name LIKE :name"] + params["name"] = f'%{kwargs["name"].strip()}%' + + if bool(kwargs.get("verified", "").strip()): + clauses = clauses + ["verified=:verified"] + params["verified"] = 1 if kwargs["verified"].strip() == "yes" else "no" + + if bool(kwargs.get("age", "").strip()): + _clause, _param = __process_age_clause__(kwargs["age"].strip()) + clauses = clauses + [_clause] + params["created"] = _param + + return clauses, params + + @authorised_p( ("system:user:list",), "You do not have the appropriate privileges to list users.", oauth2_scope="profile user") -def list_users(conn: db.DbConnection) -> tuple[User, ...]: +def list_users(conn: db.DbConnection, **kwargs) -> tuple[User, ...]: """List out all users.""" + _query = "SELECT * FROM users" + _clauses, _params = __list_user_clauses_and_params__(**kwargs) + if len(_clauses) > 0: + _query = _query + " WHERE " + " AND ".join(_clauses) + with db.cursor(conn) as cursor: - cursor.execute("SELECT * FROM users") + cursor.execute(_query, _params) return tuple(User.from_sqlite3_row(row) for row in cursor.fetchall()) def __build_resource_roles__(rows): diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py index 8135ed3..be4296b 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 ( @@ -27,6 +28,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.authorisation.resources.checks import authorised_for2 from gn_auth.auth.authorisation.resources.models import ( user_resources as _user_resources) from gn_auth.auth.authorisation.roles.models import ( @@ -38,6 +40,7 @@ from gn_auth.auth.errors import ( NotFoundError, UsernameError, PasswordError, + AuthorisationError, UserRegistrationError) @@ -113,6 +116,30 @@ def user_address(user: User) -> Address: """Compute the `email.headerregistry.Address` from a `User`""" return Address(display_name=user.name, addr_spec=user.email) + +def display_minutes_for_humans(minutes): + """Convert minutes into human-readable display.""" + _week_ = 10080 # minutes + _day_ = 1440 # minutes + _remainder_ = minutes + + _human_readable_ = "" + if _remainder_ >= _week_: + _weeks_ = _remainder_ // _week_ + _remainder_ = _remainder_ % _week_ + _human_readable_ += f"{_weeks_} week" + ("s" if _weeks_ > 1 else "") + + if _remainder_ >= _day_: + _days_ = _remainder_ // _day_ + _remainder_ = _remainder_ % _day_ + _human_readable_ += (" " if bool(_human_readable_) else "") + \ + f"{_days_} day" + ("s" if _days_ > 1 else "") + + if _remainder_ > 0: + _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_remainder_} minutes" + + return _human_readable_ + def send_verification_email( conn, user: User, @@ -123,8 +150,8 @@ 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() - expiration_minutes = 15 + generated = datetime.now() + expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"] def __render__(template): return render_template(template, subject=subject, @@ -136,7 +163,8 @@ def send_verification_email( client_id=client_id, redirect_uri=redirect_uri, verificationcode=verification_code)), - expiration_minutes=expiration_minutes) + expiration_minutes=display_minutes_for_humans( + expiration_minutes)) with db.cursor(conn) as cursor: cursor.execute( ("INSERT INTO " @@ -148,12 +176,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"), @@ -178,7 +207,7 @@ def register_user() -> Response: with db.cursor(conn) as cursor: user, _hashed_password = set_user_password( cursor, save_user( - cursor, email["email"], user_name), password) + cursor, email["email"], user_name), password) # type: ignore assign_default_roles(cursor, user) send_verification_email(conn, user, @@ -187,14 +216,14 @@ 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( + raise Exception(# pylint: disable=[broad-exception-raised] "unknown_error", "The system experienced an unexpected error.") def delete_verification_code(cursor, code: str): @@ -235,11 +264,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) @@ -303,22 +333,54 @@ def user_join_request_exists(): @require_oauth("profile user") def list_all_users() -> Response: """List all the users.""" - with require_oauth.acquire("profile group") as _the_token: - return jsonify(tuple( - asdict(user) for user in with_db_connection(list_users))) + _kwargs = { + key: value + for key, value in request.json.items() + if key in ("email", "name", "verified", "age") + } + + with (require_oauth.acquire("profile group") as _the_token, + db.connection(current_app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + _users = list_users(conn, **_kwargs) + _start = int(_kwargs.get("start", "0")) + _length = int(_kwargs.get("length", "0")) + cursor.execute("SELECT COUNT(*) FROM users") + _total_users = int(cursor.fetchone()["COUNT(*)"]) + return jsonify({ + "users": tuple(asdict(user) for user in + (_users[_start:_start+_length] + if _length else _users)), + "total-users": _total_users, + "total-filtered": len(_users) + }) @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 +412,221 @@ 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 = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"] + 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=display_minutes_for_humans( + 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 + + +@users.route("/delete", methods=["POST"]) +@require_oauth("profile user role") +def delete_users(): + """Delete the specified user.""" + with (require_oauth.acquire("profile") as _token, + db.connection(current_app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + if not authorised_for2(conn, + _token.user, + system_resource(conn), + ("system:user:delete-user",)): + raise AuthorisationError( + "You need the `system:user:delete-user` privilege to delete " + "users from the system.") + + _form = request_json() + _user_ids = _form.get("user_ids", []) + _non_deletable = set((str(_token.user.user_id),)) + + cursor.execute("SELECT user_id FROM group_users") + _non_deletable.update(row["user_id"] for row in cursor.fetchall()) + + cursor.execute("SELECT user_id FROM oauth2_clients;") + _non_deletable.update(row["user_id"] for row in cursor.fetchall()) + + _important_roles = ( + "group-leader", + "resource-owner", + "system-administrator", + "inbredset-group-owner") + _paramstr = ",".join(["?"] * len(_important_roles)) + cursor.execute( + "SELECT DISTINCT user_roles.user_id FROM user_roles " + "INNER JOIN roles ON user_roles.role_id=roles.role_id " + f"WHERE roles.role_name IN ({_paramstr})", + _important_roles) + _non_deletable.update(row["user_id"] for row in cursor.fetchall()) + + _delete = tuple(uid for uid in _user_ids if uid not in _non_deletable) + _paramstr = ", ".join(["?"] * len(_delete)) + if len(_delete) > 0: + _dependent_tables = ( + ("authorisation_code", "user_id"), + ("forgot_password_tokens", "user_id"), + ("group_join_requests", "requester_id"), + ("jwt_refresh_tokens", "user_id"), + ("oauth2_tokens", "user_id"), + ("user_credentials", "user_id"), + ("user_roles", "user_id"), + ("user_verification_codes", "user_id")) + for _table, _col in _dependent_tables: + cursor.execute( + f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})", + _delete) + + cursor.execute( + f"DELETE FROM users WHERE user_id IN ({_paramstr})", + _delete) + _deleted_rows = cursor.rowcount + _diff = len(_user_ids) - _deleted_rows + return jsonify({ + "total-requested": len(_user_ids), + "total-deleted": _deleted_rows, + "not-deleted": _diff, + "message": ( + f"Successfully deleted {_deleted_rows} users." + + (f" Some users could not be deleted." if _diff > 0 else "")) + }) + + return jsonify({ + "total-requested": len(_user_ids), + "total-deleted": 0, + "not-deleted": len(_user_ids), + "error": "Zero users were deleted", + "error_description": ( + "Either no users were selected or all the selected users are " + "system administrators, group members, or resource owners.") + }), 400 diff --git a/gn_auth/auth/db/mariadb.py b/gn_auth/auth/db/mariadb.py deleted file mode 100644 index a36e9d3..0000000 --- a/gn_auth/auth/db/mariadb.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Connections to MariaDB""" -import logging -import traceback -import contextlib -from urllib.parse import urlparse -from typing import Any, Tuple, Protocol, Iterator - -import MySQLdb as mdb - -class DbConnection(Protocol): - """Type annotation for a generic database connection object.""" - def cursor(self, *args, **kwargs) -> Any: - """A cursor object""" - - def commit(self, *args, **kwargs) -> Any: - """Commit the transaction.""" - - def rollback(self) -> Any: - """Rollback the transaction.""" - -def parse_db_url(sql_uri: str) -> Tuple: - """Parse SQL_URI env variable note:there is a default value for SQL_URI so a - tuple result is always expected""" - parsed_db = urlparse(sql_uri) - return ( - parsed_db.hostname, parsed_db.username, parsed_db.password, - parsed_db.path[1:], parsed_db.port) - -@contextlib.contextmanager -def database_connection(sql_uri) -> Iterator[DbConnection]: - """Connect to MySQL database.""" - host, user, passwd, db_name, port = parse_db_url(sql_uri) - connection = mdb.connect(db=db_name, - user=user, - passwd=passwd or '', - host=host, - port=port or 3306) - try: - yield connection - except mdb.Error as _mdb_err: - logging.debug(traceback.format_exc()) - connection.rollback() - finally: - connection.commit() - connection.close() 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..cd939dd 100644 --- a/gn_auth/auth/requests.py +++ b/gn_auth/auth/requests.py @@ -1,6 +1,14 @@ """Utilities to deal with requests.""" +import werkzeug from flask import request def request_json() -> dict: """Retrieve the JSON sent in a request.""" - return request.json or {} + try: + json_data = request.json + # KLUDGE: We have this check here since request.json has the + # type Any | None; see: + # <https://github.com/pallets/werkzeug/blob/7868bef5d978093a8baa0784464ebe5d775ae92a/src/werkzeug/wrappers/request.py#L545> + return json_data if isinstance(json_data, dict) else {} + except werkzeug.exceptions.UnsupportedMediaType: + return 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") diff --git a/gn_auth/debug.py b/gn_auth/debug.py new file mode 100644 index 0000000..6b7173b --- /dev/null +++ b/gn_auth/debug.py @@ -0,0 +1,22 @@ +"""Debug utilities""" +import logging +from flask import current_app + +__this_module_name__ = __name__ + + +# pylint: disable=invalid-name +def getLogger(name: str): + """Return a logger""" + return ( + logging.getLogger(name) + if not bool(current_app) + else current_app.logger) + +def __pk__(*args): + """Format log entry""" + value = args[-1] + title_vals = " => ".join(args[0:-1]) + logger = getLogger(__this_module_name__) + logger.debug("%s: %s", title_vals, value) + return value diff --git a/gn_auth/errors.py b/gn_auth/errors.py index 1b6bc81..4b6007a 100644 --- a/gn_auth/errors.py +++ b/gn_auth/errors.py @@ -8,7 +8,7 @@ from gn_auth.auth.errors import AuthorisationError def add_trace(exc: Exception, errobj: dict) -> dict: """Add the traceback to the error handling object.""" - current_app.logger.debug("Endpoint: %s\n%s", + current_app.logger.error("Endpoint: %s\n%s", request.url, traceback.format_exception(exc)) return { @@ -18,6 +18,7 @@ def add_trace(exc: Exception, errobj: dict) -> dict: def page_not_found(exc): """404 handler.""" + current_app.logger.error(f"Page '{request.url}' was not found.", exc_info=True) content_type = request.content_type if bool(content_type) and content_type.lower() == "application/json": return jsonify(add_trace(exc, { @@ -31,10 +32,12 @@ def page_not_found(exc): def handle_general_exception(exc: Exception): """Handle generic unhandled exceptions.""" + current_app.logger.error("Error occurred!", exc_info=True) content_type = request.content_type if bool(content_type) and content_type.lower() == "application/json": + exc_args = [str(x) for x in exc.args] msg = ("The following exception was raised while attempting to access " - f"{request.url}: {' '.join(exc.args)}") + f"{request.url}: {' '.join(exc_args)}") return jsonify(add_trace(exc, { "error": type(exc).__name__, "error_description": msg @@ -48,6 +51,7 @@ def handle_general_exception(exc: Exception): def handle_authorisation_error(exc: AuthorisationError): """Handle AuthorisationError if not handled anywhere else.""" + current_app.logger.error("Error occurred!", exc_info=True) current_app.logger.error(exc) return jsonify(add_trace(exc, { "error": type(exc).__name__, diff --git a/gn_auth/hooks.py b/gn_auth/hooks.py new file mode 100644 index 0000000..bd7380b --- /dev/null +++ b/gn_auth/hooks.py @@ -0,0 +1,68 @@ +"""Authorisation hooks implementation""" +import functools +from typing import List + +from flask import request_finished +from flask import request, current_app + +from gn_auth.auth.db import sqlite3 as db + +def register_hooks(app): + """Initialise hooks system on the application.""" + request_finished.connect(edu_domain_hook, app) + + +def handle_register_request(func): + """Decorator for handling user registration hooks.""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + if request.method == "POST" and request.endpoint == "oauth2.users.register_user": + return func(*args, **kwargs) + else: + return lambda *args, **kwargs: None + return wrapper + + +@handle_register_request +def edu_domain_hook(_sender, response, **_extra): + """Hook to run whenever a user with a `.edu` domain registers.""" + if response.status_code >= 400: + return + data = request.get_json() + if data is None or "email" not in data or not data["email"].endswith("edu"): + return + registered_email = data["email"] + apply_edu_role(registered_email) + + +def apply_edu_role(email): + """Assign 'hook-role-from-edu-domain' to user.""" + with db.connection(current_app.config["AUTH_DB"]) as conn: + with db.cursor(conn) as cursor: + cursor.execute("SELECT user_id FROM users WHERE email= ?", (email,) ) + user_result = cursor.fetchone() + cursor.execute("SELECT role_id FROM roles WHERE role_name='hook-role-from-edu-domain'") + role_result = cursor.fetchone() + resource_ids = get_resources_for_edu_domain(cursor) + if user_result is None or role_result is None: + return + user_id = user_result[0] + role_id = role_result[0] + cursor.executemany( + "INSERT INTO user_roles(user_id, role_id, resource_id) " + "VALUES(:user_id, :role_id, :resource_id)", + tuple({ + "user_id": user_id, + "role_id": role_id, + "resource_id": resource_id + } for resource_id in resource_ids)) + + +def get_resources_for_edu_domain(cursor) -> List[int]: + """FIXME: I still haven't figured out how to get resources to be assigned to edu domain""" + resources_query = """ + SELECT resource_id FROM resources INNER JOIN resource_categories USING(resource_category_id) WHERE resource_categories.resource_category_key IN ('genotype', 'phenotype', 'mrna') + """ + cursor.execute(resources_query) + resource_ids = [x[0] for x in cursor.fetchall()] + return resource_ids diff --git a/gn_auth/jobs.py b/gn_auth/jobs.py index 8f9f4f0..7cd5945 100644 --- a/gn_auth/jobs.py +++ b/gn_auth/jobs.py @@ -24,7 +24,7 @@ def job(redisconn: Redis, job_id: UUID) -> Either: if the_job: return Right({ key: json.loads(value, object_hook=jed.custom_json_decoder) - for key, value in the_job.items() + for key, value in the_job.items() # type: ignore }) return Left({ "error": "NotFound", diff --git a/gn_auth/misc_views.py b/gn_auth/misc_views.py index bd2ad62..2abad4a 100644 --- a/gn_auth/misc_views.py +++ b/gn_auth/misc_views.py @@ -2,9 +2,10 @@ Miscellaneous top-level views that have nothing to do with the application's functionality. """ +import os from pathlib import Path -from flask import Blueprint +from flask import Blueprint, current_app as app, send_from_directory misc = Blueprint("misc", __name__) @@ -16,3 +17,11 @@ def version(): with open(version_file, encoding="utf-8") as verfl: return verfl.read().strip() return "0.0.0" + + +@misc.route("/favicon.ico", methods=["GET"]) +def favicon(): + """Return the favicon.""" + return send_from_directory(os.path.join(app.root_path, "static"), + "images/CITGLogo.png", + mimetype="image/png") diff --git a/gn_auth/session.py b/gn_auth/session.py index 7226ac5..39f6959 100644 --- a/gn_auth/session.py +++ b/gn_auth/session.py @@ -47,11 +47,11 @@ def session_expired() -> bool: return now >= session[__SESSION_KEY__]["expires"] return True -def update_expiry() -> bool: +def update_expiry(minutes: int = 10) -> bool: """Update the session expiry and return a boolean indicating success.""" if not session_expired(): now = datetime.now(tz=timezone.utc) - session[__SESSION_KEY__]["expires"] = now + timedelta(minutes=10) + session[__SESSION_KEY__]["expires"] = now + timedelta(minutes=minutes) return True return False diff --git a/gn_auth/settings.py b/gn_auth/settings.py index 7dc0105..d59e997 100644 --- a/gn_auth/settings.py +++ b/gn_auth/settings.py @@ -8,6 +8,9 @@ LOGLEVEL = "WARNING" SECRET_KEY = "" GN_AUTH_SECRETS = None # Set this to path to secrets file +# Session settings +SESSION_EXPIRY_MINUTES = 10 + # Database settings SQL_URI = "mysql://webqtlout:webqtlout@localhost/db_webqtl" AUTH_DB = f"{os.environ.get('HOME')}/genenetwork/gn3_files/db/auth.db" @@ -18,9 +21,11 @@ REDIS_URI = "redis://localhost:6379/0" REDIS_JOB_QUEUE = "GN_AUTH::job-queue" # OAuth2 settings -OAUTH2_SCOPE = ( - "profile", "group", "role", "resource", "user", "masquerade", - "introspect") +OAUTH2_SCOPES_SUPPORTED = ( + # Used by Authlib's `authlib.integrations.flask_oauth2.AuthorizationServer` + # class to setup the supported scopes. + "profile", "group", "role", "resource", "register-client", "user", + "masquerade", "introspect", "migrate-data") CORS_ORIGINS = "*" CORS_HEADERS = [ @@ -29,9 +34,9 @@ CORS_HEADERS = [ "Access-Control-Allow-Credentials" ] -# OpenSSL keys -CLIENTS_SSL_PUBLIC_KEYS_DIR = "" # clients' public keys' directory -SSL_PRIVATE_KEY = "" # authorisation server primary key +# JSON Web Keys (JWKs) +JWKS_ROTATION_AGE_DAYS = 7 # Days (from creation) to keep a JWK in use. +JWKS_DELETION_AGE_DAYS = 14 # Days (from creation) to keep a JWK around before deleting it. ## Email SMTP_HOST = "smtp.genenetwork.org" # does not actually exist right now @@ -39,3 +44,8 @@ SMTP_PORT = 587 SMTP_TIMEOUT = 200 # seconds SMTP_USER = "no-reply@genenetwork.org" SMTP_PASSWORD = "asecrettoken" +EMAIL_ADDRESS = "no-reply@uthsc.edu" + + +## Variable settings for various emails going out to users +AUTH_EMAILS_EXPIRY_MINUTES = 15 diff --git a/gn_auth/smtp.py b/gn_auth/smtp.py index 0c9f878..0040f35 100644 --- a/gn_auth/smtp.py +++ b/gn_auth/smtp.py @@ -16,18 +16,18 @@ def __read_mime__(filepath) -> dict: return {} -def build_email_message(# pylint: disable=[too-many-arguments] +def build_email_message(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + from_address: str, to_addresses: tuple[Address, ...], subject: str, txtmessage: str, htmlmessage: str = "", - attachments: tuple[str, ...] = tuple(), - from_address: Address = Address( - "GeneNetwork Automated Emails", "no-reply", "genenetwork.org") + attachments: tuple[str, ...] = tuple() ) -> EmailMessage: """Build an email message.""" msg = EmailMessage() - msg["From"] = from_address + msg["From"] = Address(display_name="GeneNetwork Automated Emails", + addr_spec=from_address) msg["To"] = to_addresses msg["Subject"] = subject msg.set_content(txtmessage) @@ -40,7 +40,7 @@ def build_email_message(# pylint: disable=[too-many-arguments] return msg -def send_message(# pylint: disable=[too-many-arguments] +def send_message(# pylint: disable=[too-many-arguments, too-many-positional-arguments] smtp_user: str, smtp_passwd: str, message: EmailMessage, @@ -53,6 +53,9 @@ def send_message(# pylint: disable=[too-many-arguments] """Set up a connection to a SMTP server and send a message.""" logging.debug("Email to send:\n******\n%s\n******\n", message.as_string()) with smtplib.SMTP(host, port, local_hostname, timeout, source_address) as conn: - conn.starttls() - conn.login(smtp_user, smtp_passwd) + conn.ehlo() + if bool(smtp_user) and bool(smtp_passwd): + conn.starttls() + conn.login(smtp_user, smtp_passwd) + conn.send_message(message) diff --git a/gn_auth/static/images/CITGLogo.png b/gn_auth/static/images/CITGLogo.png Binary files differnew file mode 100644 index 0000000..ae99fed --- /dev/null +++ b/gn_auth/static/images/CITGLogo.png diff --git a/gn_auth/templates/admin/confirm-change-client-secret.html b/gn_auth/templates/admin/confirm-change-client-secret.html new file mode 100644 index 0000000..aa8ef81 --- /dev/null +++ b/gn_auth/templates/admin/confirm-change-client-secret.html @@ -0,0 +1,45 @@ +{%extends "base.html"%} + +{%block title%}gn-auth: View OAuth2 Client{%endblock%} + +{%block pagetitle%}View OAuth2 Client{%endblock%} + +{%block content%} +{{flash_messages()}} + +<h2>Change Oauth2 Client Secret</h2> + +<p>You are attempting to change the <strong>CLIENT_SECRET</strong> value for the + following client:</p> + +<table class="table"> + <tbody> + <tr> + <td><strong>Client ID</strong></td> + <td>{{client.client_id}}</td> + </tr> + <tr> + <td><strong>Client Name</strong></td> + <td>{{client.client_metadata.client_name}}</td> + </tr> + </tbody> +</table> + +<p>Are you absolutely sure you want to do this?<br /> + <small>Note that you'll need to update your configurations for the client and + restart it for the settings to take effect!</small></p> + +<form id="frm-change-client-secret" + method="POST" + action="{{url_for('oauth2.admin.change_client_secret', + client_id=client.client_id)}}"> + + <input type="hidden" name="client_id" value="{{client.client_id}}" /> + <input type="hidden" name="client_name" value="{{client.client_metadata.client_name}}" /> + + <div class="form-group"> + <input type="submit" class="btn btn-danger" value="generate new secret" /> + </div> +</form> + +{%endblock%} diff --git a/gn_auth/templates/admin/list-oauth2-clients.html b/gn_auth/templates/admin/list-oauth2-clients.html index ca0ee6d..6da5b2f 100644 --- a/gn_auth/templates/admin/list-oauth2-clients.html +++ b/gn_auth/templates/admin/list-oauth2-clients.html @@ -15,7 +15,7 @@ <th>Client Name</th> <th>Default Redirect URI</th> <th>Owner</th> - <th colspan="2">Actions</th> + <th colspan="3">Actions</th> </tr> </thead> @@ -43,6 +43,14 @@ class="btn btn-danger" /> </form> </td> + <td> + <a href="{{url_for('oauth2.admin.change_client_secret', + client_id=client.client_id)}}" + title="Change the client secret!" + class="btn btn-danger"> + Change Secret + </a> + </td> </tr> {%else%} <tr> diff --git a/gn_auth/templates/admin/register-client.html b/gn_auth/templates/admin/register-client.html index 20d7aa2..bfe56f8 100644 --- a/gn_auth/templates/admin/register-client.html +++ b/gn_auth/templates/admin/register-client.html @@ -9,59 +9,72 @@ <form method="POST" action="{{url_for('oauth2.admin.register_client')}}"> - <fieldset> - <legend>Select client scope</legend> - + <legend>Select client scope</legend> + <div class="form-group"> {%for scp in scope%} - <input name="scope[]" id="chk-{{scp}}"type="checkbox" value="{{scp}}" - {%if scp=="profile"%}checked="checked"{%endif%} /> - <label for="chk-{{scp}}">{{scp}}</label><br /> + <div class="checkbox"> + <label for="chk-{{scp}}"> + <input name="scope[]" id="chk-{{scp}}"type="checkbox" value="{{scp}}" + {%if scp=="profile"%}checked="checked"{%endif%} /> + {{scp}} + </label> + </div> {%endfor%} + </div> - </fieldset> - - <fieldset> - <legend>Basic OAuth2 client information</legend> - - - <label for="txt-client-name">Client name</label> - <input name="client_name" type="text" id="txt-client-name" + <legend>Basic OAuth2 client information</legend> + <div class="form-group"> + <label for="txt-client-name" class="form-label">Client name</label> + <input name="client_name" + type="text" + id="txt-client-name" + class="form-control" required="required" /> - <br /><br /> + </div> - <label for="txt-redirect-uri">Redirect URI</label> - <input name="redirect_uri" type="text" id="txt-redirect-uri" + <div class="form-group"> + <label for="txt-redirect-uri" class="form-label">Redirect URI</label> + <input name="redirect_uri" + type="text" + id="txt-redirect-uri" + class="form-control" required="required" /> - <br /><br /> + </div> - <label for="txt-other-redirect-uris"> - Other redirect URIs (Enter one URI per line)</label> - <br /> - <textarea name="other_redirect_uris" id="txt-other-redirect-uris" + <div class="form-group"> + <label for="txt-other-redirect-uris" class="form-label"> + Other redirect URIs</label> + <div class="form-text text-muted">Enter one URI per line</div> + <textarea name="other_redirect_uris" + id="txt-other-redirect-uris" cols="80" rows="10" + class="form-control" title="Enter one URI per line."></textarea> - <br /><br /> - <fieldset> - <legend>Supported grant types</legend> - {%for granttype in granttypes%} - <input name="grants[]" - type="checkbox" - value="{{granttype.value}}" - id="chk-{{granttype.name.lower().replace(' ', '-')}}" - checked="checked" /> + </div> + + <div class="form-group"> + <legend>Supported grant types</legend> + {%for granttype in granttypes%} + <div class="checkbox"> <label for="chk-{{granttype.name.lower().replace(' ', '-')}}"> + <input name="grants[]" + type="checkbox" + value="{{granttype.value}}" + id="chk-{{granttype.name.lower().replace(' ', '-')}}" + checked="checked" /> {{granttype.name}} </label> - <br /><br /> - {%endfor%} - </fieldset> - </fieldset> - - <fieldset> - <legend>User information</legend> + </div> + {%endfor%} + </div> - <p>The user to register this client for</p> - <select name="user" required="required"> + <legend>User information</legend> + <div class="form-group"> + <label for="select-user">The user to register this client for</label> + <select id="select-user" + name="user" + class="form-control" + required="required"> {%for user in users%} <option value="{{user.user_id}}" {%if user.user_id==current_user.user_id%} @@ -69,8 +82,18 @@ {%endif%}>{{user.name}} ({{user.email}})</option> {%endfor%} </select> - </fieldset> - - <input type="submit" value="register client" /> + </div> + + <legend>Other metadata</legend> + <div class="form-group"> + <label class="form-group" for="txt-client-jwk-uri"> + Client's Public JWKs</label> + <input type="text" + id="txt-client-jwk-uri" + name="client_jwk_uri" + class="form-control" /> + </div> + + <input type="submit" value="register client" class="btn btn-primary" /> </form> {%endblock%} diff --git a/gn_auth/templates/admin/view-oauth2-client.html b/gn_auth/templates/admin/view-oauth2-client.html index 415873d..c250ee3 100644 --- a/gn_auth/templates/admin/view-oauth2-client.html +++ b/gn_auth/templates/admin/view-oauth2-client.html @@ -13,118 +13,82 @@ {%set client = client.value%} <form method="POST" action="{{url_for('oauth2.admin.edit_client')}}"> <legend>View/Edit Oauth2 Client</legend> + <input type="hidden" name="client_id" value="{{client.client_id}}" /> <input type="hidden" name="client_name" value="{{client.client_metadata.client_name}}" /> + <div> - <p><strong>Client ID: <strong> {{client.client_id}}</p> - <p><strong>Client Name: <strong> {{client.client_metadata.client_name}}</p> + <p><strong>Client ID: </strong> {{client.client_id}}</p> + <p><strong>Client Name: </strong> {{client.client_metadata.client_name}}</p> </div> - <fieldset> + + <div class="form-group"> <legend>Scope</legend> {%for scp in scope%} - <input name="scope[]" id="chk:{{scp}}" type="checkbox" value="{{scp}}" - {%if scp in client.client_metadata.scope%} - checked="checked" - {%endif%} /> - <label for="chk:{{scp}}">{{scp}}</label><br /> + <div class="checkbox"> + <label for="chk:{{scp}}"> + <input name="scope[]" id="chk:{{scp}}" type="checkbox" value="{{scp}}" + {%if scp in client.client_metadata.scope%} + checked="checked" + {%endif%} /> + {{scp}}</label><br /> + </div> {%endfor%} - </fieldset> + </div> - <fieldset> + <div class="form-group"> <legend>Redirect URIs</legend> - <label for="txt-redirect-uri">Default Redirect URI</label> + <label for="txt-redirect-uri" class="form-label">Default Redirect URI</label> <br /> - <input type="text" name="redirect_uri" id="txt-redirect-uri" + <input type="text" + name="redirect_uri" + id="txt-redirect-uri" value="{{client.client_metadata.default_redirect_uri}}" required="required" class="form-control" /> - <br /><br /> + </div> - <label for="txta:other-redirect-uris">Other Redirect URIs</label> - <br /> + <div class="form-group"> + <label for="txta:other-redirect-uris" + class="form-label">Other Redirect URIs</label> <textarea id="txta:other-redirect-uris" name="other_redirect_uris" cols="80" rows="10" + class="form-control" title="Enter one URI per line." >{{"\r\n".join(client.client_metadata.redirect_uris)}}</textarea> - </fieldset> + </div> - <fieldset> + <div class="form-group"> <legend>Grants</legend> - {%for granttype in granttypes%} - <input name="grants[]" - type="checkbox" - value="{{granttype.value}}" - id="chk-{{granttype.name.lower().replace(' ', '-')}}" - {%if granttype.value in client.client_metadata.grant_types%} - checked="checked" - {%endif%} /> + {%for granttype in granttypes%} + <div class="checkbox"> <label for="chk-{{granttype.name.lower().replace(' ', '-')}}"> + <input name="grants[]" + type="checkbox" + value="{{granttype.value}}" + id="chk-{{granttype.name.lower().replace(' ', '-')}}" + {%if granttype.value in client.client_metadata.grant_types%} + checked="checked" + {%endif%} /> {{granttype.name}} </label> - <br /><br /> - {%endfor%} - </fieldset> - - <input type="submit" class="btn btn-primary" value="update client" /> -</form> - -<hr /> -<h2>Signing/Verification SSL Keys</h2> -<table> - <caption>Registered Public Keys</caption> - <thead> - <tr> - <th>JWK Thumbprint</th> - <th>Actions</th> - </tr> - </thead> - - <tbody> - {%for sslkey in client.jwks.keys:%} - <tr> - <td>{{sslkey.thumbprint()}}</td> - <td> - <form method="POST" - action="{{url_for('oauth2.admin.delete_client_public_key')}}"> - <input type="hidden" - name="client_id" - value="{{client.client_id}}" /> - <input type="hidden" - name="ssl_key" - value="{{sslkey.thumbprint()}}" /> - <input type="submit" - class="btn btn-danger" - value="delete key" /> - </form> - </td> - </tr> - {%else%} - <tr> - <td class="alert-warning" - colspan="2"> - There are no registered SSL keys for this client. - </td> - </tr> + </div> {%endfor%} - </tbody> -</table> -<form id="frm-client-add-ssl-key" - method="POST" - action="{{url_for('oauth2.admin.register_client_public_key')}}"> - <legend>Register new SSL key</legend> - <input type="hidden" name="client_id" value="{{client.client_id}}" /> - <fieldset> - <label for="txt-area-client-ssl-key">Client's Public Key</label> - <textarea id="txt-area-client-ssl-key" - name="client_ssl_key" - required="required" - class="form-control" - rows="10"></textarea> - </fieldset> + </div> + + <legend>Other metadata</legend> + <div class="form-group"> + <label class="form-group" for="txt-client-jwk-uri"> + Client's Public JWKs</label> + <input type="text" + id="txt-client-jwk-uri" + name="client_jwk_uri" + class="form-control" + value="{{client.client_metadata.get('public-jwks-uri', '')}}" /> + </div> - <br /> - <input type="submit" class="btn btn-primary" value="register key" /> + <input type="submit" class="btn btn-primary" value="update client" /> </form> {%endif%} diff --git a/gn_auth/templates/base.html b/gn_auth/templates/base.html index b452ca1..c90ac9b 100644 --- a/gn_auth/templates/base.html +++ b/gn_auth/templates/base.html @@ -5,7 +5,7 @@ <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>gn-auth: {%block title%}{%endblock%}</title> + <title>Authorization {%block title%}{%endblock%}</title> <link rel="stylesheet" type="text/css" href="https://genenetwork.org/static/new/css/bootstrap-custom.css" /> @@ -39,7 +39,7 @@ style="font-weight: bold;">GeneNetwork</a> </li> <li> - <a href="#">gn-auth: {%block pagetitle%}{%endblock%}</a> + <a href="#">{%block pagetitle%}{%endblock%}</a> </li> </ul> </div> diff --git a/gn_auth/templates/emails/forgot-password.html b/gn_auth/templates/emails/forgot-password.html new file mode 100644 index 0000000..5f16a02 --- /dev/null +++ b/gn_auth/templates/emails/forgot-password.html @@ -0,0 +1,38 @@ +<html> + <head> + <meta charset="UTF-8" /> + <title>{{subject}}</title> + </head> + <body> + <p> + You (or someone pretending to be you) made a request to change your + password. Please follow the link below to change it. + </p> + + <p> + Click the button below to change your password + <a href="{{forgot_password_uri}}" + style="display: block;text-align: center;vertical-align: center;cursor: pointer;border-radius: 4px;background-color: #336699;border-color: #357ebd;color: white;text-decoration: none;font-size: large;width: 9em;text-transform: capitalize;margin: 1em 0 0 3em;box-shadow: 2px 2px rgba(0, 0, 0, 0.3);">Change my Password</a>.</p> + + <p> + Or copy the link below onto your browser's address bar:<br /><br /> + <span style="font-weight: bolder;">{{forgot_password_uri}}</span> + </p> + + <p> + If you did not request to change your password, simply ignore this email. + </p> + + <p style="font-weight: bold;color: #ee55ee;"> + The link will expire in <strong>{{expiration_minutes}}</strong>. + </p> + + <hr /> + <p> + <small> + Note that if you requested to change your password multiple times, only + the latest/newest token will be valid. + </small> + </p> + </body> +</html> diff --git a/gn_auth/templates/emails/forgot-password.txt b/gn_auth/templates/emails/forgot-password.txt new file mode 100644 index 0000000..68abf16 --- /dev/null +++ b/gn_auth/templates/emails/forgot-password.txt @@ -0,0 +1,12 @@ +{{subject}} +=============== + +You (or someone pretending to be you) made a request to change your password. Please copy the link below onto your browser to change your password: + +{{forgot_password_uri}} + +If you did not request to change your password, simply ignore this email. + +The link will expire in {{expiration_minutes}}. + +Note that if you requested to change your password multiple times, only the latest/newest token will be valid. diff --git a/gn_auth/templates/emails/verify-email.html b/gn_auth/templates/emails/verify-email.html index 7f85c1c..11ae575 100644 --- a/gn_auth/templates/emails/verify-email.html +++ b/gn_auth/templates/emails/verify-email.html @@ -20,7 +20,7 @@ <p style="font-weight: bold;color: #ee55ee;"> Please note that the verification code will expire in - <strong>{{expiration_minutes}}</strong> minutes after it was generated. + <strong>{{expiration_minutes}}</strong> after it was generated. </p> </body> </html> diff --git a/gn_auth/templates/emails/verify-email.txt b/gn_auth/templates/emails/verify-email.txt index 281d682..ecfbfc0 100644 --- a/gn_auth/templates/emails/verify-email.txt +++ b/gn_auth/templates/emails/verify-email.txt @@ -9,4 +9,4 @@ If that does not work, please log in to GeneNetwork and copy the verification co {{verification_code}} -Please note that the verification code will expire {{expiration_minutes}} minutes after it was generated. +Please note that the verification code will expire {{expiration_minutes}} after it was generated. diff --git a/gn_auth/templates/oauth2/authorise-user.html b/gn_auth/templates/oauth2/authorise-user.html index d69bdd4..f186167 100644 --- a/gn_auth/templates/oauth2/authorise-user.html +++ b/gn_auth/templates/oauth2/authorise-user.html @@ -2,34 +2,65 @@ {%block title%}Authorise User{%endblock%} -{%block pagetitle%}Authenticate to the API Server{%endblock%} +{%block pagetitle%}{%endblock%} {%block content%} {{flash_messages()}} +<div class="container" style="min-width: 1250px;"> + <form method="POST" + class="form-horizontal" + action="{{url_for( + 'oauth2.auth.authorise', + response_type=response_type, + client_id=client.client_id, + redirect_uri=redirect_uri)}}" + style="max-width: 700px;"> + <legend style="margin-top: 20px;">Sign In</legend> -<form method="POST" action="{{url_for( - 'oauth2.auth.authorise', - response_type=response_type, - client_id=client.client_id, - redirect_uri=redirect_uri)}}"> - <input type="hidden" name="response_type" value="{{response_type}}" /> - <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" /> - <input type="hidden" name="scope" value="{{scope | join(' ')}}" /> - <input type="hidden" name="client_id" value="{{client.client_id}}" /> + <input type="hidden" name="response_type" value="{{response_type}}" /> + <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" /> + <input type="hidden" name="scope" value="{{scope | join(' ')}}" /> + <input type="hidden" name="client_id" value="{{client.client_id}}" /> - <legend>User Credentials</legend> - <div class="form-group"> - <label for="user:email" class="form-label">Email</label> - <input type="email" name="user:email" id="user:email" required="required" - class="form-control"/> - </div> + <div class="form-group"> + <label for="user:email" class="control-label col-xs-2" + style="text-align: left;">Email</label> + <div class="col-xs-10"> + <input type="email" + name="user:email" + id="user:email" + required="required" + class="form-control" /> + </div> + </div> - <div class="form-group"> - <label for="user:password" class="form-label">Password</label> - <input type="password" name="user:password" id="user:password" - required="required" class="form-control" /> - </div> - - <input type="submit" value="authorise" class="btn btn-primary" /> -</form> + <div class="form-group"> + <label for="user:password" class="control-label col-xs-2" + style="text-align: left;">Password</label> + <div class="col-xs-10"> + <input type="password" + name="user:password" + id="user:password" + required="required" + class="form-control" /> + </div> + </div> + + <div class="form-group"> + <div class="controls col-xs-offset-2 col-xs-10"> + <input type="submit" value="Sign in" class="btn btn-primary" /> + {%if display_forgot_password%} + <a href="{{url_for('oauth2.users.forgot_password', + client_id=client.client_id, + redirect_uri=redirect_uri, + response_type=response_type)}}" + title="Click here to change your password." + class="form-text text-danger">Forgot Password</a> + {%endif%} + </div> + </div> + <hr> + <a href="{{ source_uri }}/oauth2/user/register" class="btn btn-primary" role="button">Create a New Account</a> + </form> +</div> {%endblock%} diff --git a/gn_auth/templates/users/change-password.html b/gn_auth/templates/users/change-password.html new file mode 100644 index 0000000..f328255 --- /dev/null +++ b/gn_auth/templates/users/change-password.html @@ -0,0 +1,52 @@ +{%extends "base.html"%} + +{%block title%}gn-auth: Change Password{%endblock%} + +{%block pagetitle%}Change Password{%endblock%} + +{%block content%} +{{flash_messages()}} + +<div class="container-fluid"> + <div class="row"><h1>Change Password</h1></div> + + <div class="row"> + <form method="POST" + action="{{url_for('oauth2.users.change_password', + client_id=client_id, + redirect_uri=redirect_uri, + response_type=response_type, + forgot_password_token=forgot_password_token)}}"> + <div class="form-group"> + <p class="form-text text-info"> + Change the password for your account with the email + "<strong>{{email}}</strong>". + </p> + </div> + + <div class="form-group"> + <label for="txt-password" class="form-label">New Password</label> + <input type="password" + id="txt-password" + name="password" + class="form-control" + required="required" /> + </div> + + <div class="form-group"> + <label for="txt-confirm" class="form-label">Confirm New Password</label> + <input type="password" + id="txt-confirm" + name="confirm-password" + class="form-control" + required="required" /> + </div> + + <div class="form-group"> + <input type="submit" class="btn btn-danger" value="change password" /> + </div> + </form> + </div> + +</div> +{%endblock%} diff --git a/gn_auth/templates/users/forgot-password-token-send-success.html b/gn_auth/templates/users/forgot-password-token-send-success.html new file mode 100644 index 0000000..8782e8c --- /dev/null +++ b/gn_auth/templates/users/forgot-password-token-send-success.html @@ -0,0 +1,22 @@ +{%extends "base.html"%} + +{%block title%}gn-auth: Forgot Password{%endblock%} + +{%block pagetitle%}Forgot Password{%endblock%} + +{%block content%} +{{flash_messages()}} + +<div class="container-fluid"> + <div class="row"><h1>Forgot Password</h1></div> + + <div class="row"> + <p class="text-info" + style="font-size:1.5em;text-align:center;margin-top:2em;"> + We have sent an email to '<strong>{{email}}</strong>'. Please use the link + in the email we sent to change your password. + </p> + </div> + +</div> +{%endblock%} diff --git a/gn_auth/templates/users/forgot-password.html b/gn_auth/templates/users/forgot-password.html new file mode 100644 index 0000000..0455c69 --- /dev/null +++ b/gn_auth/templates/users/forgot-password.html @@ -0,0 +1,38 @@ +{%extends "base.html"%} + +{%block title%}gn-auth: Forgot Password{%endblock%} + +{%block pagetitle%}Forgot Password{%endblock%} + +{%block content%} +{{flash_messages()}} + +<div class="container-fluid"> + <div class="row"><h1>Forgot Password</h1></div> + + <div class="row"> + <form method="POST" + action="{{url_for('oauth2.users.forgot_password', + client_id=client_id, + redirect_uri=redirect_uri, + response_type=response_type)}}"> + <div class="form-group"> + <span> + Provide you email below, and we will send you a link you can use to + change your password. + </span> + </div> + + <div class="form-group"> + <label for="txt-email" class="form-label">Email</label> + <input type="email" name="email" id="txt-email" class="form-control" /> + </div> + + <div class="form-group"> + <input type="submit" class="btn btn-primary" value="Send Link" /> + </div> + </form> + </div> + +</div> +{%endblock%} diff --git a/gn_auth/templates/users/unverified-user.html b/gn_auth/templates/users/unverified-user.html index 0ce141d..fcd34ad 100644 --- a/gn_auth/templates/users/unverified-user.html +++ b/gn_auth/templates/users/unverified-user.html @@ -7,69 +7,87 @@ {%block content%} {{flash_messages()}} -<h1>Verify Your E-Mail</h1> - -<form id="frm-email-verification" method="POST" - action="{{url_for('oauth2.users.verify_user')}}"> - <legend>Email Verification</legend> - - <p>In order to reduce the number of bots we have to deal with, we no longer - allow sign-in with users who have not verified their accounts.</p> - - <p>We know this is annoying — especially if you already have an account, - and have been using it just fine — however, we have found that without - this check in place, we will get overrun by silly bots, which will ruin - every user's experience.</p> - - <p> - Do bear with us, enter the verification code you received via email below: - </p> - - <input type="hidden" name="email" value="{{email}}" /> - <input type="hidden" name="response_type" value="{{response_type}}" /> - <input type="hidden" name="client_id" value="{{client_id}}" /> - <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" /> - - <fieldset class="form-group"> - <label for="txt-verification-code" class="form-label"> - Verification Code</label> - <input id="txt-verification-code" name="verificationcode" type="text" - required="required" class="form-control" - placeholder="Enter your verification code here." /> - </fieldset> - - <fieldset> - <input type="submit" value="Verify Email Address" class="btn btn-primary" /> - </fieldset> -</form> - -<h2>Send Verification Code</h2> - -<form id="frm-send-verification-code" method="POST" - action="{{url_for('oauth2.users.send_verification_code')}}"> - <legend>Send Verification Code</legend> - - <p>If you have not received a verification code, or your code is already - expired, provide <strong>your GeneNetwork</strong> password and - click the "<em>Send Verification Code</em>" button below and we will send - you a new verification code.</p> - - <input type="hidden" name="user_email" value="{{email}}" /> - <input type="hidden" name="response_type" value="{{response_type}}" /> - <input type="hidden" name="client_id" value="{{client_id}}" /> - <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" /> - - <fieldset class="form-group"> - <label class="form-label">Email</label> - <label class="form-control">{{email}}</label> - </fieldset> - - <fieldset class="form-group"> - <label for="txt-password" class="form-label">Password</label> - <input id="txt-password" name="user_password" type="password" - placeholder="Enter your GeneNetwork password" - class="form-control" /> - </fieldset> - <input type="submit" value="Send Verification Code" class="btn btn-danger" /> -</form> +<div class="container-fluid"> + <div class="row"><h1>Verify Your E-Mail</h1></div> + + {%if token_found:%} + <div class="row"> + <form id="frm-email-verification" method="POST" + action="{{url_for('oauth2.users.verify_user')}}"> + <legend>Email Verification</legend> + + <p>If you are seeing this, your account needs to be verified.</p> + + <p>An email with a verification token has already been sent to the address + associated with this account (<em>{{email}}</em>). Please provide that + verification token below and click the "<em>Verify Email Address</em>" + button to verify your account.</p> + + <input type="hidden" name="email" value="{{email}}" /> + <input type="hidden" name="response_type" value="{{response_type}}" /> + <input type="hidden" name="client_id" value="{{client_id}}" /> + <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" /> + + <fieldset class="form-group"> + <label for="txt-verification-code" class="form-label"> + Verification Code</label> + <input id="txt-verification-code" name="verificationcode" type="text" + required="required" class="form-control" + placeholder="Enter your verification code here." /> + </fieldset> + + <fieldset> + <input type="submit" value="Verify Email Address" class="btn btn-primary" /> + </fieldset> + </form> + </div> + {%else:%} + <div class="row"> + <form id="frm-send-verification-code" method="POST" + action="{{url_for('oauth2.users.send_verification_code')}}"> + <legend>Send Verification Code</legend> + + <p>Provide your password below, and we will send you a verification password + to your email.</p> + <p>You are seeing this page because:</p> + <ol type="a"> + <li>You already had an existing account.<br /> + In this case, you will need to request a verification code by + providing your email below and clicking the + "<em>Send Verification Code</em>" button.<br /> + We will send you an email with both: + <ol type="1"> + <li>a link you can click to verify your email, <strong>and</strong> + </li> + <li>a token to copy and paste if you choose not to follow the link. + </li> + </ol> + </li> + <li>You registered your account recently, but did not verify it within the + time period allocated for that. In this case, simply request a new + verification email below, and follow the link, or copy and paste the + token in the email we send you.</li> + </ol> + + <input type="hidden" name="user_email" value="{{email}}" /> + <input type="hidden" name="response_type" value="{{response_type}}" /> + <input type="hidden" name="client_id" value="{{client_id}}" /> + <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" /> + + <fieldset class="form-group"> + <label class="form-label">Email</label> + <label class="form-control">{{email}}</label> + </fieldset> + + <fieldset class="form-group"> + <label for="txt-password" class="form-label">Password</label> + <input id="txt-password" name="user_password" type="password" + placeholder="Enter your GeneNetwork password" + class="form-control" /> + </fieldset> + <input type="submit" value="Send Verification Code" class="btn btn-danger" /> + </form> + </div> + {%endif%} +</div> {%endblock%} diff --git a/gn_auth/wsgi.py b/gn_auth/wsgi.py index 811a0d5..e05ef0d 100644 --- a/gn_auth/wsgi.py +++ b/gn_auth/wsgi.py @@ -1,16 +1,12 @@ """Main entry point for project""" -import os import sys import uuid import json -import logging from math import ceil from pathlib import Path -from typing import Callable from datetime import datetime import click -from flask import Flask from yoyo import get_backend, read_migrations from gn_auth import migrations @@ -22,39 +18,9 @@ from gn_auth.auth.authentication.users import user_by_id, hash_password from gn_auth.auth.authorisation.users.admin.models import make_sys_admin from scripts import register_sys_admin as rsysadm# type: ignore[import] -from scripts import migrate_existing_data as med# type: ignore[import] -def dev_loggers(appl: Flask) -> None: - """Setup the logging handlers.""" - stderr_handler = logging.StreamHandler(stream=sys.stderr) - appl.logger.addHandler(stderr_handler) - - root_logger = logging.getLogger() - root_logger.addHandler(stderr_handler) - root_logger.setLevel(appl.config["LOGLEVEL"]) - - -def gunicorn_loggers(appl: Flask) -> None: - """Use gunicorn logging handlers for the application.""" - logger = logging.getLogger("gunicorn.error") - appl.logger.handlers = logger.handlers - appl.logger.setLevel(logger.level) - - -def setup_loggers() -> Callable[[Flask], None]: - """ - Setup the loggers according to the WSGI server used to run the application. - """ - # https://datatracker.ietf.org/doc/html/draft-coar-cgi-v11-03#section-4.1.17 - # https://wsgi.readthedocs.io/en/latest/proposals-2.0.html#making-some-keys-required - # https://peps.python.org/pep-3333/#id4 - software, *_version_and_comments = os.environ.get( - "SERVER_SOFTWARE", "").split('/') - return gunicorn_loggers if bool(software) else dev_loggers - -# app = create_app() -app = create_app(setup_logging=setup_loggers()) +app = create_app() ##### BEGIN: CLI Commands ##### @@ -67,8 +33,14 @@ def apply_migrations(): def __init_dev_users__(): """Initialise dev users. Get's used in more than one place""" - dev_users_query = "INSERT INTO users VALUES (:user_id, :email, :name)" - dev_users_passwd = "INSERT INTO user_credentials VALUES (:user_id, :hash)" + dev_users_query = """ + INSERT INTO users (user_id, email, name, verified) + VALUES (:user_id, :email, :name, 1) + ON CONFLICT(email) DO UPDATE SET + name=excluded.name, + verified=excluded.verified + """ + dev_users_passwd = "INSERT OR REPLACE INTO user_credentials VALUES (:user_id, :hash)" dev_users = ({ "user_id": "0ad1917c-57da-46dc-b79e-c81c91e5b928", "email": "test@development.user", @@ -91,18 +63,26 @@ def init_dev_users(): __init_dev_users__() @app.cli.command() -def init_dev_clients(): +@click.option('--client-uri', default= "http://localhost:5033", type=str) +def init_dev_clients(client_uri): """ Initialise a development client for OAuth2 sessions. **NOTE**: You really should not run this in production/staging """ + client_uri = client_uri.lstrip("/") __init_dev_users__() - dev_clients_query = ( - "INSERT INTO oauth2_clients VALUES (" - ":client_id, :client_secret, :client_id_issued_at, " - ":client_secret_expires_at, :client_metadata, :user_id" - ")") + dev_clients_query = """ + INSERT INTO oauth2_clients VALUES ( + :client_id, :client_secret, :client_id_issued_at, + :client_secret_expires_at, :client_metadata, :user_id + ) + ON CONFLICT(client_id) DO UPDATE SET + client_secret=excluded.client_secret, + client_secret_expires_at=excluded.client_secret_expires_at, + client_metadata=excluded.client_metadata, + user_id=excluded.user_id + """ dev_clients = ({ "client_id": "0bbfca82-d73f-4bd4-a140-5ae7abb4a64d", "client_secret": "yadabadaboo", @@ -113,10 +93,12 @@ def init_dev_clients(): "token_endpoint_auth_method": [ "client_secret_post", "client_secret_basic"], "client_type": "confidential", - "grant_types": ["password", "authorization_code", "refresh_token"], - "default_redirect_uri": "http://localhost:5033/oauth2/code", - "redirect_uris": ["http://localhost:5033/oauth2/code", - "http://localhost:5033/oauth2/token"], + "grant_types": ["password", "authorization_code", "refresh_token", + "urn:ietf:params:oauth:grant-type:jwt-bearer"], + "default_redirect_uri": f"{client_uri}/oauth2/code", + "redirect_uris": [f"{client_uri}/oauth2/code", + f"{client_uri}/oauth2/token"], + "public-jwks-uri": f"{client_uri}/oauth2/public-jwks", "response_type": ["code", "token"], "scope": ["profile", "group", "role", "resource", "register-client", "user", "masquerade", "migrate-data", "introspect"] @@ -141,11 +123,6 @@ def assign_system_admin(user_id: uuid.UUID): sys.exit(1) @app.cli.command() -def make_data_public(): - """Make existing data that is not assigned to any group publicly visible.""" - med.entry(app.config["AUTH_DB"], app.config["SQL_URI"]) - -@app.cli.command() def register_admin(): """Register the administrator.""" rsysadm.register_admin(Path(app.config["AUTH_DB"])) diff --git a/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py b/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py new file mode 100644 index 0000000..44318bd --- /dev/null +++ b/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py @@ -0,0 +1,26 @@ +""" +Create forgot_password_tokens table + +This will be used to enable users to validate/verify their password change +requests. +""" + +from yoyo import step + +__depends__ = {'20240606_03_BY7Us-drop-group-roles-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS forgot_password_tokens( + user_id TEXT NOT NULL, + token TEXT NOT NULL, + generated INTEGER NOT NULL, + expires INTEGER NOT NULL, + PRIMARY KEY(user_id), + FOREIGN KEY(user_id) REFERENCES users(user_id) + ON UPDATE CASCADE ON DELETE CASCADE + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS forgot_password_tokens") +] diff --git a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py b/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py new file mode 100644 index 0000000..5c6e81d --- /dev/null +++ b/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py @@ -0,0 +1,24 @@ +""" +hooks_for_edu_domains +""" + +from yoyo import step + +__depends__ = {'20240819_01_p2vXR-create-forgot-password-tokens-table'} + +steps = [ + step( + """ + INSERT INTO roles(role_id, role_name, user_editable) VALUES + ('9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d', 'hook-role-from-edu-domain', '0') + """, + "DELETE FROM roles WHERE role_name='hook-role-from-edu-domain'"), + step( + """ + INSERT INTO role_privileges(role_id, privilege_id) VALUES + ('9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d', 'group:resource:view-resource'), + ('9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d', 'group:resource:edit-resource') + """, + "DELETE FROM role_privileges WHERE role_id='9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d'" + ) +] diff --git a/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py b/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py new file mode 100644 index 0000000..d22ad01 --- /dev/null +++ b/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py @@ -0,0 +1,42 @@ +""" +add admin ui privilege to system-administrator role +""" +import contextlib + +from yoyo import step + +__depends__ = {'20240924_01_thbvh-hooks-for-edu-domains'} + +def get_system_admin_id(cursor): + cursor.execute( + "SELECT role_id FROM roles WHERE role_name='system-administrator'") + return cursor.fetchone()[0] + +def add_admin_ui_privilege(conn): + with contextlib.closing(conn.cursor()) as cursor: + # Create admin-ui privilege + cursor.execute( + "INSERT INTO privileges (privilege_id, privilege_description) " + "VALUES(?, ?)", + ("system:user:admin-ui", "View UI elements that should only be visible to system administrators")) + + # Add UI privilege to system-administrator role + cursor.execute( + "INSERT INTO role_privileges (role_id, privilege_id) " + "VALUES(?, ?)", + (get_system_admin_id(cursor), "system:user:admin-ui") + ) + +def remove_admin_ui_privilege(conn): + with contextlib.closing(conn.cursor()) as cursor: + # Remove UI privilege from system-administrator role + cursor.execute( + "DELETE FROM role_privileges WHERE privilege_id='system:user:admin-ui'") + + # Remove UI privilege from privileges table + cursor.execute( + "DELETE FROM privileges WHERE privilege_id='system:user:admin-ui'") + +steps = [ + step(add_admin_ui_privilege, remove_admin_ui_privilege) +] diff --git a/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py b/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py new file mode 100644 index 0000000..73a4880 --- /dev/null +++ b/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py @@ -0,0 +1,49 @@ +""" +Add Batch Edit privileges +""" + +import contextlib + +from yoyo import step + +__depends__ = {'20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role'} + +def add_batch_edit_privilege_and_role(conn): + with contextlib.closing(conn.cursor()) as cursor: + # Create batch edit privilege + cursor.execute( + "INSERT INTO privileges (privilege_id, privilege_description) " + "VALUES(?, ?)", + ("system:data:batch-edit", "Batch Edit")) + + # Create batch editor role + cursor.execute( + "INSERT INTO roles (role_id, role_name, user_editable) " + "VALUES(?, ?, ?)", + ("0f391910-5225-476a-bb8d-9c0adc9d81cc", "Batch Editors", 0)) + + # Link role/privilege + cursor.execute( + "INSERT INTO role_privileges (role_id, privilege_id) " + "VALUES(?, ?)", + ("0f391910-5225-476a-bb8d-9c0adc9d81cc", "system:data:batch-edit") + ) + +def remove_batch_edit_privilege_and_role(conn): + with contextlib.closing(conn.cursor()) as cursor: + # Remove batch edit role/privilege link + cursor.execute( + "DELETE FROM role_privileges WHERE privilege_id='system:data:batch-edit'") + + # Remove Batch Editor role + cursor.execute( + "DELETE FROM roles WHERE role_id='0f391910-5225-476a-bb8d-9c0adc9d81cc'") + + # Remove Batch Edit privilege + cursor.execute( + "DELETE FROM privileges WHERE privilege_id='system:data:batch-edit'") + + +steps = [ + step(add_batch_edit_privilege_and_role, remove_batch_edit_privilege_and_role) +] diff --git a/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py b/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py new file mode 100644 index 0000000..3b9e928 --- /dev/null +++ b/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py @@ -0,0 +1,19 @@ +""" +Add new 'group:data:link-to-group' privilege. +""" + +from yoyo import step + +__depends__ = {'20240924_01_thbvh-hooks-for-edu-domains'} + +steps = [ + step( + """ + INSERT INTO privileges(privilege_id, privilege_description) + VALUES( + 'group:data:link-to-group', + 'Allow linking data to only one specific group.' + ) + """, + "DELETE FROM privileges WHERE privilege_id='group:data:link-to-group'") +] diff --git a/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py b/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py new file mode 100644 index 0000000..5d9c306 --- /dev/null +++ b/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py @@ -0,0 +1,23 @@ +""" +Assign 'group:data:link-to-group' privilege to group leader. +""" + +from yoyo import step + +__depends__ = {'20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege'} + +steps = [ + step( + """ + INSERT INTO role_privileges(role_id, privilege_id) + VALUES( + 'a0e67630-d502-4b9f-b23f-6805d0f30e30', + 'group:data:link-to-group' + ) + """, + """ + DELETE FROM role_privileges + WHERE role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30' + AND privilege_id='group:data:link-to-group' + """) +] @@ -71,4 +71,7 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-flask_cors.*] +ignore_missing_imports = True + +[mypy-gn_libs.*] ignore_missing_imports = True
\ No newline at end of file diff --git a/scripts/migrate_existing_data.py b/scripts/assign_data_to_default_admin.py index 198d37d..69fc50c 100644 --- a/scripts/migrate_existing_data.py +++ b/scripts/assign_data_to_default_admin.py @@ -1,6 +1,6 @@ """ -Migrate existing data that is not assigned to any group to the default sys-admin -group for accessibility purposes. +Assign any existing data (that is not currently assigned to any group) to the +default sys-admin group for accessibility purposes. """ import sys import json @@ -11,10 +11,9 @@ from pathlib import Path from uuid import UUID, uuid4 import click +from gn_libs import mysqldb as biodb from MySQLdb.cursors import DictCursor -from gn_auth.auth.db import mariadb as biodb - import gn_auth.auth.db.sqlite3 as authdb from gn_auth.auth.authentication.users import User from gn_auth.auth.authorisation.roles.models import ( @@ -22,8 +21,8 @@ from gn_auth.auth.authorisation.roles.models import ( from gn_auth.auth.authorisation.resources.groups.models import ( Group, save_group, add_resources_to_group) -from gn_auth.auth.authorisation.resources.models import ( - Resource, ResourceCategory, __assign_resource_owner_role__) +from gn_auth.auth.authorisation.resources.common import assign_resource_owner_role +from gn_auth.auth.authorisation.resources.models import Resource, ResourceCategory class DataNotFound(Exception): @@ -412,7 +411,8 @@ def entry(authdbpath, mysqldburi): assign_data_to_resource( authconn, bioconn, resource, the_admin_group) with authdb.cursor(authconn) as cursor: - __assign_resource_owner_role__(cursor, resource, admin) + assign_resource_owner_role( + cursor, resource.resource_id, admin.user_id) except DataNotFound as dnf: print(dnf.args[0], file=sys.stderr) sys.exit(1) diff --git a/scripts/batch_assign_data_to_default_admin.py b/scripts/batch_assign_data_to_default_admin.py new file mode 100644 index 0000000..a468019 --- /dev/null +++ b/scripts/batch_assign_data_to_default_admin.py @@ -0,0 +1,87 @@ +""" +Similar to the 'assign_data_to_default_admin' script but without user +interaction. +""" +import sys +import logging +from pathlib import Path + +import click +from gn_libs import mysqldb as biodb +from pymonad.maybe import Just, Maybe, Nothing +from pymonad.tools import monad_from_none_or_value + +from gn_auth.auth.db import sqlite3 as authdb +from gn_auth.auth.authentication.users import User +from gn_auth.auth.authorisation.resources.groups.models import ( + Group, db_row_to_group) + +from scripts.assign_data_to_default_admin import ( + default_resources, assign_data_to_resource) + + +def resources_group(conn: authdb.DbConnection) -> Maybe: + """Retrieve resources' group""" + with authdb.cursor(conn) as cursor: + cursor.execute( + "SELECT g.* FROM resources AS r " + "INNER JOIN resource_ownership AS ro " + "ON r.resource_id=ro.resource_id " + "INNER JOIN groups AS g ON ro.group_id=g.group_id " + "WHERE resource_name='mRNA-euhrin'") + return monad_from_none_or_value( + Nothing, Just, cursor.fetchone()).then( + db_row_to_group) + + +def resource_owner(conn: authdb.DbConnection) -> Maybe: + """Retrieve the resource owner.""" + with authdb.cursor(conn) as cursor: + cursor.execute( + "SELECT u.* FROM users AS u WHERE u.user_id IN " + "(SELECT ur.user_id FROM resources AS rsc " + "INNER JOIN user_roles AS ur ON rsc.resource_id=ur.resource_id " + "INNER JOIN roles AS r on ur.role_id=r.role_id " + "WHERE resource_name='mRNA-euhrin' " + "AND r.role_name='resource-owner')") + return monad_from_none_or_value( + Nothing, Just, cursor.fetchone()).then( + User.from_sqlite3_row) + + +def assign_data(authconn: authdb.DbConnection, bioconn, group: Group): + """Do actual data assignments.""" + try: + for resource in default_resources(authconn, group): + assign_data_to_resource(authconn, bioconn, resource, group) + + return 1 + except Exception as _exc:# pylint: disable=[broad-except] + logging.error("Failed to assign some data!", exc_info=True) + return 1 + + +if __name__ == "__main__": + @click.command() + @click.argument("authdbpath") # "Path to the Auth(entic|oris)ation database" + @click.argument("mysqldburi") # "URI to the MySQL database with the biology data" + @click.option("--loglevel", + default="WARNING", + show_default=True, + type=click.Choice([ + "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"])) + def run(authdbpath, mysqldburi, loglevel): + """Script entry point.""" + _logger = logging.getLogger() + _logger.setLevel(loglevel) + if Path(authdbpath).exists(): + with (authdb.connection(authdbpath) as authconn, + biodb.database_connection(mysqldburi) as bioconn): + return resources_group(authconn).maybe( + 1, + lambda group: assign_data(authconn, bioconn, group)) + + logging.error("There is no such SQLite3 database file.") + return 1 + + sys.exit(run()) # pylint: disable=[no-value-for-parameter] diff --git a/scripts/link_inbredsets.py b/scripts/link_inbredsets.py index ac9fa2b..c78a050 100644 --- a/scripts/link_inbredsets.py +++ b/scripts/link_inbredsets.py @@ -6,12 +6,12 @@ import uuid from pathlib import Path import click +from gn_libs import mysqldb as biodb import gn_auth.auth.db.sqlite3 as authdb -from gn_auth.auth.db import mariadb as biodb - -from scripts.migrate_existing_data import sys_admins, admin_group, select_sys_admin +from scripts.assign_data_to_default_admin import ( + sys_admins, admin_group, select_sys_admin) def linked_inbredsets(conn): """Fetch all inbredset groups that are linked to the auth system.""" diff --git a/scripts/register_sys_admin.py b/scripts/register_sys_admin.py index dfd4d59..06aa845 100644 --- a/scripts/register_sys_admin.py +++ b/scripts/register_sys_admin.py @@ -16,7 +16,7 @@ def fetch_email() -> str: try: user_input = input("Enter the administrator's email: ") email = validate_email(user_input.strip(), check_deliverability=True) - return email["email"] + return email["email"] # type: ignore except EmailNotValidError as _enve: print("You did not provide a valid email address. Try again...", file=sys.stderr) diff --git a/scripts/search_phenotypes.py b/scripts/search_phenotypes.py index 20d91c9..eee112d 100644 --- a/scripts/search_phenotypes.py +++ b/scripts/search_phenotypes.py @@ -11,9 +11,9 @@ from datetime import datetime, timedelta import click import redis import requests +from gn_libs import mysqldb as gn3db from gn_auth import jobs -from gn_auth.auth.db import mariadb as gn3db from gn_auth.auth.db import sqlite3 as authdb from gn_auth.settings import SQL_URI, AUTH_DB from gn_auth.auth.authorisation.data.phenotypes import linked_phenotype_data @@ -26,7 +26,7 @@ def do_search( """Do the search and return the results""" search_uri = urljoin(host, (f"search/?page={page}&per_page={per_page}" f"&type=phenotype&query={query}")) - response = requests.get(search_uri) + response = requests.get(search_uri, timeout=300) results = response.json() if len(results) > 0: return (item for item in results) @@ -52,7 +52,7 @@ def update_search_results(redisconn: redis.Redis, redisname: str, results: tuple[dict[str, Any], ...]): """Save the results to redis db.""" key = "search_results" - prev_results = tuple(json.loads(redisconn.hget(redisname, key) or "[]")) + prev_results = tuple(json.loads(redisconn.hget(redisname, key) or "[]")) # type: ignore redisconn.hset(redisname, key, json.dumps(prev_results + results)) def expire_redis_results(redisconn: redis.Redis, redisname: str): @@ -75,7 +75,7 @@ def expire_redis_results(redisconn: redis.Redis, redisname: str): @click.option( "--redis-uri", default="redis://:@localhost:6379/0", help="The URI to the redis server.") -def search(# pylint: disable=[too-many-arguments, too-many-locals] +def search(# pylint: disable=[too-many-arguments, too-many-positional-arguments, too-many-locals] species: str, query: str, job_id: uuid.UUID, host: str, per_page: int, selected: str, auth_db_uri: str, gn3_db_uri: str, redis_uri: str): """ @@ -13,18 +13,18 @@ setup(author="Frederick M. Muriithi", description=( "Authentication/Authorisation server for GeneNetwork Services."), install_requires=[ - "argon2-cffi>=20.1.0" - "click" - "Flask==1.1.2" - "mypy==0.790" - "mypy-extensions==0.4.3" - "mysqlclient==2.0.1" - "pylint==2.5.3" - "pymonad" - "redis==3.5.3" - "requests==2.25.1" - "flask-cors==3.0.9" - "xapian-bindings" + "argon2-cffi>=20.1.0", + "click", + "Flask>=1.1.2", + "mypy>=0.790", + "mypy-extensions>=0.4.3", + "mysqlclient>=2.0.1", + "pylint>=2.5.3", + "pymonad", + "redis>=3.5.3", + "requests>=2.25.1", + "flask-cors>=3.0.9", + "gn-libs>=0.0.0" ], include_package_data=True, packages=find_packages( diff --git a/tests/unit/auth/fixtures/role_fixtures.py b/tests/unit/auth/fixtures/role_fixtures.py index 1858712..63a3fca 100644 --- a/tests/unit/auth/fixtures/role_fixtures.py +++ b/tests/unit/auth/fixtures/role_fixtures.py @@ -163,7 +163,7 @@ def fxtr_system_roles(fxtr_users): @pytest.fixture(scope="function") -def fxtr_resource_user_roles(# pylint: disable=[too-many-arguments, too-many-locals] +def fxtr_resource_user_roles(# pylint: disable=[too-many-arguments, too-many-locals, too-many-positional-arguments] fxtr_resources, fxtr_users_in_group, fxtr_resource_ownership, diff --git a/tests/unit/auth/test_groups.py b/tests/unit/auth/test_groups.py index 16df56e..346beb9 100644 --- a/tests/unit/auth/test_groups.py +++ b/tests/unit/auth/test_groups.py @@ -27,7 +27,7 @@ PRIVILEGES = ( @pytest.mark.unit_test @pytest.mark.parametrize("user", tuple(conftest.TEST_USERS[0:3])) -def test_create_group_fails(# pylint: disable=[too-many-arguments] +def test_create_group_fails(# pylint: disable=[too-many-arguments too-many-positional-arguments] fxtr_app, auth_testdb_path, mocker, fxtr_resource_user_roles, fxtr_oauth2_clients, user):# pylint: disable=[unused-argument] """ GIVEN: an authenticated user @@ -71,7 +71,7 @@ def __cleanup_create_group__(conn, user, group): ((conftest.TEST_USERS[3], Group( UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_group", {"group_description": "A test group"})),)) -def test_create_group_succeeds(# pylint: disable=[too-many-arguments, unused-argument] +def test_create_group_succeeds(# pylint: disable=[too-many-arguments too-many-positional-arguments, unused-argument] fxtr_app, auth_testdb_path, mocker, @@ -102,7 +102,7 @@ def test_create_group_succeeds(# pylint: disable=[too-many-arguments, unused-arg @pytest.mark.unit_test @pytest.mark.parametrize("user", conftest.TEST_USERS[1:]) -def test_create_group_raises_exception_with_non_privileged_user(# pylint: disable=[too-many-arguments] +def test_create_group_raises_exception_with_non_privileged_user(# pylint: disable=[too-many-arguments too-many-positional-arguments] fxtr_app, auth_testdb_path, mocker, fxtr_users, fxtr_oauth2_clients, user):# pylint: disable=[unused-argument] """ GIVEN: an authenticated user, without appropriate privileges diff --git a/tests/unit/auth/test_migrations_add_data_to_table.py b/tests/unit/auth/test_migrations_add_data_to_table.py index d9e2ca4..0945a20 100644 --- a/tests/unit/auth/test_migrations_add_data_to_table.py +++ b/tests/unit/auth/test_migrations_add_data_to_table.py @@ -40,7 +40,7 @@ test_params = ( @pytest.mark.unit_test @pytest.mark.parametrize("migration_file,query,query_params,data", test_params) -def test_apply_insert(# pylint: disable=[too-many-arguments] +def test_apply_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments] auth_migrations_dir, backend, auth_testdb_path, migration_file, query, query_params, data): """ @@ -65,7 +65,7 @@ def test_apply_insert(# pylint: disable=[too-many-arguments] @pytest.mark.unit_test @pytest.mark.parametrize("migration_file,query,query_params,data", test_params) -def test_rollback_insert(# pylint: disable=[too-many-arguments] +def test_rollback_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments] auth_migrations_dir, backend, auth_testdb_path, migration_file, query, query_params, data): """ diff --git a/tests/unit/auth/test_migrations_add_remove_columns.py b/tests/unit/auth/test_migrations_add_remove_columns.py index af85652..15dc3a2 100644 --- a/tests/unit/auth/test_migrations_add_remove_columns.py +++ b/tests/unit/auth/test_migrations_add_remove_columns.py @@ -51,7 +51,7 @@ def rolled_back_successfully(adding: bool, result_str: str, column: str) -> bool @pytest.mark.unit_test @pytest.mark.parametrize( "migration_file,the_table,the_column,adding", TEST_PARAMS) -def test_apply_add_remove_column(# pylint: disable=[too-many-arguments] +def test_apply_add_remove_column(# pylint: disable=[too-many-arguments too-many-positional-arguments] auth_migrations_dir, auth_testdb_path, backend, migration_file, the_table, the_column, adding): """ @@ -84,7 +84,7 @@ def test_apply_add_remove_column(# pylint: disable=[too-many-arguments] @pytest.mark.unit_test @pytest.mark.parametrize( "migration_file,the_table,the_column,adding", TEST_PARAMS) -def test_rollback_add_remove_column(# pylint: disable=[too-many-arguments] +def test_rollback_add_remove_column(# pylint: disable=[too-many-arguments too-many-positional-arguments] auth_migrations_dir, auth_testdb_path, backend, migration_file, the_table, the_column, adding): """ diff --git a/tests/unit/auth/test_migrations_indexes.py b/tests/unit/auth/test_migrations_indexes.py index 1c543c4..2d0997f 100644 --- a/tests/unit/auth/test_migrations_indexes.py +++ b/tests/unit/auth/test_migrations_indexes.py @@ -30,7 +30,7 @@ migrations_tables_and_indexes = ( @pytest.mark.unit_test @pytest.mark.parametrize( "migration_file,the_table,the_index", migrations_tables_and_indexes) -def test_index_created(# pylint: disable=[too-many-arguments] +def test_index_created(# pylint: disable=[too-many-arguments too-many-positional-arguments] auth_testdb_path, auth_migrations_dir, backend, migration_file, the_table, the_index): """ @@ -61,7 +61,7 @@ def test_index_created(# pylint: disable=[too-many-arguments] @pytest.mark.unit_test @pytest.mark.parametrize( "migration_file,the_table,the_index", migrations_tables_and_indexes) -def test_index_dropped(# pylint: disable=[too-many-arguments] +def test_index_dropped(# pylint: disable=[too-many-arguments too-many-positional-arguments] auth_testdb_path, auth_migrations_dir, backend, migration_file, the_table, the_index): """ diff --git a/tests/unit/auth/test_migrations_insert_data_into_empty_table.py b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py index 0cf9a1f..c699e81 100644 --- a/tests/unit/auth/test_migrations_insert_data_into_empty_table.py +++ b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py @@ -16,7 +16,7 @@ test_params = ( @pytest.mark.unit_test @pytest.mark.parametrize( "migration_file,table,row_count", test_params) -def test_apply_insert(# pylint: disable=[too-many-arguments] +def test_apply_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments] auth_testdb_path, auth_migrations_dir, backend, migration_file, table, row_count): """ @@ -45,7 +45,7 @@ def test_apply_insert(# pylint: disable=[too-many-arguments] @pytest.mark.unit_test @pytest.mark.parametrize( "migration_file,table,row_count", test_params) -def test_rollback_insert(# pylint: disable=[too-many-arguments] +def test_rollback_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments] auth_testdb_path, auth_migrations_dir, backend, migration_file, table, row_count): """ diff --git a/tests/unit/auth/test_privileges.py b/tests/unit/auth/test_privileges.py index 619ccc1..9b2ea04 100644 --- a/tests/unit/auth/test_privileges.py +++ b/tests/unit/auth/test_privileges.py @@ -24,7 +24,10 @@ PRIVILEGES = sorted( Privilege("group:resource:view-resource", "view a resource and use it in computations"), Privilege("group:resource:edit-resource", "edit/update a resource"), - Privilege("group:resource:delete-resource", "Delete a resource")), + Privilege("group:resource:delete-resource", "Delete a resource"), + + Privilege("group:data:link-to-group", + "Allow linking data to only one specific group.")), key=sort_key_privileges) @pytest.mark.unit_test diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py index 9b45b68..292f7dc 100644 --- a/tests/unit/auth/test_resources.py +++ b/tests/unit/auth/test_resources.py @@ -30,7 +30,7 @@ create_resource_failure = { (Resource( uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "test_resource", resource_category, False),)))) -def test_create_resource(# pylint: disable=[too-many-arguments, unused-argument] +def test_create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments, unused-argument] mocker, fxtr_users_in_group, fxtr_resource_user_roles, @@ -47,11 +47,11 @@ def test_create_resource(# pylint: disable=[too-many-arguments, unused-argument] user, tuple(client for client in clients if client.user == user)[0])) conn, _group, _users = fxtr_users_in_group - resource = create_resource( - conn, "test_resource", resource_category, user, False) - assert resource == expected with db.cursor(conn) as cursor: + resource = create_resource( + cursor, "test_resource", resource_category, user, _group, False) + assert resource == expected # Cleanup cursor.execute( "DELETE FROM user_roles WHERE resource_id=?", @@ -82,8 +82,15 @@ def test_create_resource_raises_for_unauthorised_users( tuple(client for client in clients if client.user == user)[0])) conn, _group, _users = fxtr_users_in_group with pytest.raises(AuthorisationError): - assert create_resource( - conn, "test_resource", resource_category, user, False) == expected + with db.cursor(conn) as cursor: + assert create_resource( + cursor, + "test_resource", + resource_category, + user, + _group, + False + ) == expected def sort_key_resources(resource): """Sort-key for resources.""" diff --git a/tests/unit/auth/test_resources_roles.py b/tests/unit/auth/test_resources_roles.py index 39a198f..e43f25c 100644 --- a/tests/unit/auth/test_resources_roles.py +++ b/tests/unit/auth/test_resources_roles.py @@ -63,7 +63,7 @@ def test_create_group_role(mocker, fxtr_users_in_group, fxtr_oauth2_clients, use "user,expected", tuple(zip(conftest.TEST_USERS[0:1], ( Role(UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_role", True, PRIVILEGES),)))) -def test_create_role(# pylint: disable=[too-many-arguments, unused-argument] +def test_create_role(# pylint: disable=[too-many-arguments, too-many-positional-arguments, unused-argument] fxtr_app, auth_testdb_path, mocker, diff --git a/tests/unit/auth/test_roles.py b/tests/unit/auth/test_roles.py index 251defb..43d84e4 100644 --- a/tests/unit/auth/test_roles.py +++ b/tests/unit/auth/test_roles.py @@ -26,7 +26,7 @@ PRIVILEGES = ( @pytest.mark.parametrize( "user,expected", tuple(zip(conftest.TEST_USERS[1:], ( create_role_failure, create_role_failure, create_role_failure)))) -def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[too-many-arguments, unused-argument] +def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[too-many-arguments, unused-argument, too-many-positional-arguments] fxtr_app, auth_testdb_path, mocker, @@ -115,6 +115,10 @@ def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[ user_editable=False, privileges=( Privilege( + "group:data:link-to-group", + "Allow linking data to only one specific group."), + + Privilege( privilege_id="group:resource:create-resource", privilege_description="Create a resource object"), Privilege( diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index dcf4003..53ee062 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -8,6 +8,17 @@ import pytest from gn_auth import create_app +def setup_secrets(rootdir: Path) -> Path: + """Setup secrets directory and file.""" + secretsfile = Path(rootdir).joinpath("secrets/secrets.py") + secretsfile.parent.mkdir(exist_ok=True) + with open(secretsfile, "w", encoding="utf8") as outfile: + outfile.write( + 'SECRET_KEY="qQIrgiK29kXZU6v8D09y4uw_sk8I4cqgNZniYUrRoUk"') + + return secretsfile + + @pytest.fixture(scope="session") def fxtr_app(): """Fixture: setup the test app""" @@ -22,8 +33,8 @@ def fxtr_app(): app = create_app({ "TESTING": True, "AUTH_DB": testdb, + "GN_AUTH_SECRETS": str(setup_secrets(testdir)), "OAUTH2_ACCESS_TOKEN_GENERATOR": "tests.unit.auth.test_token.gen_token", - "SECRET_KEY": "qQIrgiK29kXZU6v8D09y4uw_sk8I4cqgNZniYUrRoUk", "UPLOADS_DIR": testuploadsdir, "SSL_PRIVATE_KEY": f"{testsroot}/test-ssl-private-key.pem", "CLIENTS_SSL_PUBLIC_KEYS_DIR": f"{testsroot}/test-public-keys-dir" |