diff options
45 files changed, 1084 insertions, 437 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 @@ -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..973110a 100644 --- a/gn_auth/__init__.py +++ b/gn_auth/__init__.py @@ -24,7 +24,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_SCOPE") if not ((setting in app.config) and bool(app.config[setting]))) if len(undefined) > 0: raise ConfigurationError( @@ -51,22 +51,6 @@ 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()) - - 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))} - - 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 @@ -85,7 +69,6 @@ def create_app( override_settings_with_envvars(app) load_secrets_conf(app) - parse_ssl_keys(app) # ====== END: Setup configuration ====== setup_logging(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..1f53186 100644 --- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py +++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py @@ -74,7 +74,7 @@ class JWTBearerGrant(_JWTBearerGrant): def resolve_client_key(self, client, headers, payload): """Resolve client key to decode assertion data.""" - return app.config["SSL_PUBLIC_KEYS"].get(headers["kid"]) + return client.jwks().find_by_kid(headers["kid"]) def authenticate_user(self, subject): diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py new file mode 100644 index 0000000..2606ac6 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py @@ -0,0 +1,15 @@ +"""Implement model for JWTBearerToken""" +import uuid + +from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken + +from gn_auth.auth.db.sqlite3 import with_db_connection +from gn_auth.auth.authentication.users import user_by_id + +class JWTBearerToken(_JWTBearerToken): + """Overrides default JWTBearerToken class.""" + + def __init__(self, payload, header, options=None, params=None): + super().__init__(payload, header, options, params) + self.user = with_db_connection( + lambda conn:user_by_id(conn, uuid.UUID(payload["sub"]))) diff --git a/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py b/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py index 31c9147..46515c8 100644 --- a/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py +++ b/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py @@ -142,7 +142,7 @@ def link_child_token(conn: db.DbConnection, parenttoken: str, childtoken: str): "WHERE token=:parenttoken"), {"parenttoken": parent.token, "childtoken": childtoken}) - def __check_child__(parent): + def __check_child__(parent):#pylint: disable=[unused-variable] with db.cursor(conn) as cursor: cursor.execute( ("SELECT * FROM jwt_refresh_tokens WHERE token=:parenttoken"), @@ -154,15 +154,17 @@ def link_child_token(conn: db.DbConnection, parenttoken: str, childtoken: str): "activity detected.") return Right(parent) - def __revoke_and_raise_error__(_error_msg_): + def __revoke_and_raise_error__(_error_msg_):#pylint: disable=[unused-variable] load_refresh_token(conn, parenttoken).then( lambda _tok: revoke_refresh_token(conn, _tok)) raise InvalidGrantError(_error_msg_) + def __handle_not_found__(_error_msg_): + raise InvalidGrantError(_error_msg_) + load_refresh_token(conn, parenttoken).maybe( - Left("Token not found"), Right).then( - __check_child__).either(__revoke_and_raise_error__, - __link_to_child__) + Left("Token not found"), Right).either( + __handle_not_found__, __link_to_child__) def is_refresh_token_valid(token: JWTRefreshToken, client: OAuth2Client) -> bool: diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py index d31faf6..8fac648 100644 --- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py +++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py @@ -1,13 +1,14 @@ """OAuth2 Client model.""" import json +import logging import datetime -from pathlib import Path - from uuid import UUID from dataclasses import dataclass from functools import cached_property from typing import Sequence, Optional +import requests +from requests.exceptions import JSONDecodeError from authlib.jose import KeySet, JsonWebKey from authlib.oauth2.rfc6749 import ClientMixin from pymonad.maybe import Just, Maybe, Nothing @@ -57,16 +58,30 @@ class OAuth2Client(ClientMixin): """ return self.client_metadata.get("client_type", "public") - @cached_property + def jwks(self) -> KeySet: """Return this client's KeySet.""" - def __parse_key__(keypath: Path) -> JsonWebKey: - with open(keypath) as _key:# pylint: disable=[unspecified-encoding] - return JsonWebKey.import_key(_key.read()) + jwksuri = self.client_metadata.get("public-jwks-uri") + if not bool(jwksuri): + logging.debug("No Public JWKs URI set for client!") + return KeySet([]) + try: + ## IMPORTANT: This can cause a deadlock if the client is working in + ## single-threaded mode, i.e. can only serve one request + ## at a time. + return KeySet([JsonWebKey.import_key(key) + for key in requests.get(jwksuri).json()["jwks"]]) + except requests.ConnectionError as _connerr: + logging.debug( + "Could not connect to provided URI: %s", jwksuri, exc_info=True) + except JSONDecodeError as _jsonerr: + logging.debug( + "Could not convert response to JSON", exc_info=True) + except Exception as _exc:# pylint: disable=[broad-except] + logging.debug( + "Error retrieving the JWKs for the client.", exc_info=True) + return KeySet([]) - return KeySet([ - __parse_key__(Path(pth)) - for pth in self.client_metadata.get("public_keys", [])]) def check_endpoint_auth_method(self, method: str, endpoint: str) -> bool: """ @@ -77,12 +92,9 @@ class OAuth2Client(ClientMixin): * client_secret_post: Client uses the HTTP POST parameters * client_secret_basic: Client uses HTTP Basic """ - if endpoint == "token": + if endpoint in ("token", "revoke", "introspection"): return (method in self.token_endpoint_auth_method and method == "client_secret_post") - if endpoint in ("introspection", "revoke"): - return (method in self.token_endpoint_auth_method - and method == "client_secret_basic") return False @cached_property diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py index 2405ee2..9c885e2 100644 --- a/gn_auth/auth/authentication/oauth2/resource_server.py +++ b/gn_auth/auth/authentication/oauth2/resource_server.py @@ -1,11 +1,20 @@ """Protect the resources endpoints""" +from datetime import datetime, timezone, timedelta from flask import current_app as app + +from authlib.jose import jwt, KeySet, JoseError from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator +from authlib.oauth2.rfc7523 import ( + JWTBearerTokenValidator as _JWTBearerTokenValidator) from authlib.integrations.flask_oauth2 import ResourceProtector from gn_auth.auth.db import sqlite3 as db -from gn_auth.auth.authentication.oauth2.models.oauth2token import token_by_access_token +from gn_auth.auth.jwks import list_jwks, jwks_directory +from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import ( + JWTBearerToken) +from gn_auth.auth.authentication.oauth2.models.oauth2token import ( + token_by_access_token) class BearerTokenValidator(_BearerTokenValidator): """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`""" @@ -14,4 +23,47 @@ class BearerTokenValidator(_BearerTokenValidator): return token_by_access_token(conn, token_string).maybe(# type: ignore[misc] None, lambda tok: tok) +class JWTBearerTokenValidator(_JWTBearerTokenValidator): + """Validate a token using all the keys""" + token_cls = JWTBearerToken + _local_attributes = ("jwt_refresh_frequency_hours",) + + def __init__(self, public_key, issuer=None, realm=None, **extra_attributes): + """Initialise the validator class.""" + # https://docs.authlib.org/en/latest/jose/jwt.html#use-dynamic-keys + # We can simply use the KeySet rather than a specific key. + super().__init__(public_key, + issuer, + realm, + **{ + key: value for key,value + in extra_attributes.items() + if key not in self._local_attributes + }) + self._last_jwks_update = datetime.now(tz=timezone.utc) + self._refresh_frequency = timedelta(hours=int( + extra_attributes.get("jwt_refresh_frequency_hours", 6))) + + def __refresh_jwks__(self): + now = datetime.now(tz=timezone.utc) + if (now - self._last_jwks_update) >= self._refresh_frequency: + self.public_key = KeySet(list_jwks(jwks_directory(app))) + + def authenticate_token(self, token_string: str): + self.__refresh_jwks__() + for key in self.public_key.keys: + try: + claims = jwt.decode( + token_string, key, + claims_options=self.claims_options, + claims_cls=self.token_cls, + ) + claims.validate() + return claims + except JoseError as error: + app.logger.debug('Authenticate token failed. %r', error) + + return None + + require_oauth = ResourceProtector() diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py index d845c60..a8109b7 100644 --- a/gn_auth/auth/authentication/oauth2/server.py +++ b/gn_auth/auth/authentication/oauth2/server.py @@ -1,15 +1,20 @@ """Initialise the OAuth2 Server""" import uuid -import datetime from typing import Callable +from datetime import datetime from flask import Flask, current_app -from authlib.jose import jwk, jwt -from authlib.oauth2.rfc7523 import JWTBearerTokenValidator +from authlib.jose import jwt, KeySet from authlib.oauth2.rfc6749.errors import InvalidClientError from authlib.integrations.flask_oauth2 import AuthorizationServer +from authlib.oauth2.rfc6749 import OAuth2Request +from authlib.integrations.flask_helpers import create_oauth_request from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.jwks import ( + list_jwks, + jwks_directory, + newest_jwk_with_rotation) from .models.oauth2client import client as fetch_client from .models.oauth2token import OAuth2Token, save_token @@ -27,7 +32,7 @@ from .grants.jwt_bearer_grant import JWTBearerGrant, JWTBearerTokenGenerator from .endpoints.revocation import RevocationEndpoint from .endpoints.introspection import IntrospectionEndpoint -from .resource_server import require_oauth, BearerTokenValidator +from .resource_server import require_oauth, JWTBearerTokenValidator def create_query_client_func() -> Callable: @@ -45,10 +50,14 @@ def create_query_client_func() -> Callable: return __query_client__ -def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable: +def create_save_token_func(token_model: type, app: Flask) -> Callable: """Create the function that saves the token.""" def __save_token__(token, request): - _jwt = jwt.decode(token["access_token"], jwtkey) + _jwt = jwt.decode( + token["access_token"], + newest_jwk_with_rotation( + jwks_directory(app), + int(app.config["JWKS_ROTATION_AGE_DAYS"]))) _token = token_model( token_id=uuid.UUID(_jwt["jti"]), client=request.client, @@ -56,7 +65,7 @@ def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable: **{ "refresh_token": None, "revoked": False, - "issued_at": datetime.datetime.now(), + "issued_at": datetime.now(), **token }) with db.connection(current_app.config["AUTH_DB"]) as conn: @@ -70,8 +79,8 @@ def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable: client=request.client, user=request.user, issued_with=uuid.UUID(_jwt["jti"]), - issued_at=datetime.datetime.fromtimestamp(_jwt["iat"]), - expires=datetime.datetime.fromtimestamp( + issued_at=datetime.fromtimestamp(_jwt["iat"]), + expires=datetime.fromtimestamp( old_refresh_token.then( lambda _tok: _tok.expires.timestamp() ).maybe((int(_jwt["iat"]) + @@ -86,10 +95,8 @@ def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable: return __save_token__ - def make_jwt_token_generator(app): """Make token generator function.""" - _gen = JWTBearerTokenGenerator(app.config["SSL_PRIVATE_KEY"]) def __generator__(# pylint: disable=[too-many-arguments] grant_type, client, @@ -98,19 +105,32 @@ def make_jwt_token_generator(app): expires_in=None,# pylint: disable=[unused-argument] include_refresh_token=True ): - return _gen.__call__( - grant_type, - client, - user, - scope, - JWTBearerTokenGenerator.DEFAULT_EXPIRES_IN, - include_refresh_token) + return JWTBearerTokenGenerator( + newest_jwk_with_rotation( + jwks_directory(app), + int(app.config["JWKS_ROTATION_AGE_DAYS"]))).__call__( + grant_type, + client, + user, + scope, + JWTBearerTokenGenerator.DEFAULT_EXPIRES_IN, + include_refresh_token) return __generator__ + +class JsonAuthorizationServer(AuthorizationServer): + """An authorisation server using JSON rather than FORMDATA.""" + + def create_oauth2_request(self, request): + """Create an OAuth2 Request from the flask request.""" + res = create_oauth_request(request, OAuth2Request, True) + return res + + def setup_oauth2_server(app: Flask) -> None: """Set's up the oauth2 server for the flask application.""" - server = AuthorizationServer() + server = JsonAuthorizationServer() server.register_grant(PasswordGrant) # Figure out a common `code_verifier` for GN2 and GN3 and set @@ -133,11 +153,9 @@ def setup_oauth2_server(app: Flask) -> None: server.init_app( app, query_client=create_query_client_func(), - save_token=create_save_token_func( - OAuth2Token, app.config["SSL_PRIVATE_KEY"])) + save_token=create_save_token_func(OAuth2Token, app)) app.config["OAUTH2_SERVER"] = server ## Set up the token validators - require_oauth.register_token_validator(BearerTokenValidator()) require_oauth.register_token_validator( - JWTBearerTokenValidator(app.config["SSL_PRIVATE_KEY"].get_public_key())) + JWTBearerTokenValidator(KeySet(list_jwks(jwks_directory(app))))) diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py index 22437a2..d0b55b4 100644 --- a/gn_auth/auth/authentication/oauth2/views.py +++ b/gn_auth/auth/authentication/oauth2/views.py @@ -9,6 +9,7 @@ from flask import ( flash, request, url_for, + jsonify, redirect, Response, Blueprint, @@ -17,6 +18,7 @@ from flask import ( from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.db.sqlite3 import with_db_connection +from gn_auth.auth.jwks import jwks_directory, list_jwks from gn_auth.auth.errors import NotFoundError, ForbiddenAccess from gn_auth.auth.authentication.users import valid_login, user_by_email @@ -45,6 +47,14 @@ def authorise(): flash("Invalid OAuth2 client.", "alert-danger") if request.method == "GET": + def __forgot_password_table_exists__(conn): + with db.cursor(conn) as cursor: + cursor.execute("SELECT name FROM sqlite_master " + "WHERE type='table' " + "AND name='forgot_password_tokens'") + return bool(cursor.fetchone()) + return False + client = server.query_client(request.args.get("client_id")) _src = urlparse(request.args["redirect_uri"]) return render_template( @@ -53,7 +63,9 @@ def authorise(): scope=client.scope, response_type=request.args["response_type"], redirect_uri=request.args["redirect_uri"], - source_uri=f"{_src.scheme}://{_src.netloc}/") + source_uri=f"{_src.scheme}://{_src.netloc}/", + display_forgot_password=with_db_connection( + __forgot_password_table_exists__)) form = request.form def __authorise__(conn: db.DbConnection): @@ -72,7 +84,8 @@ def authorise(): url_for("oauth2.users.handle_unverified", response_type=form["response_type"], client_id=client_id, - redirect_uri=form["redirect_uri"]), + redirect_uri=form["redirect_uri"], + email=email["email"]), code=307) return server.create_authorization_response(request=request, grant_user=user) flash(email_passwd_msg, "alert-danger") @@ -116,3 +129,13 @@ def introspect_token() -> Response: IntrospectionEndpoint.ENDPOINT_NAME) raise ForbiddenAccess("You cannot access this endpoint") + + +@auth.route("/public-jwks", methods=["GET"]) +def public_jwks(): + """Provide the JWK public keys used by this application.""" + return jsonify({ + "documentation": ( + "The keys are listed in order of creation, from the oldest (first) " + "to the newest (last)."), + "jwks": tuple(key.as_dict() for key in list_jwks(jwks_directory(app)))}) diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py index ee77654..3263e37 100644 --- a/gn_auth/auth/authorisation/resources/groups/models.py +++ b/gn_auth/auth/authorisation/resources/groups/models.py @@ -5,6 +5,7 @@ from functools import reduce from dataclasses import dataclass from typing import Any, Sequence, Iterable, Optional +import sqlite3 from flask import g from pymonad.maybe import Just, Maybe, Nothing @@ -63,6 +64,13 @@ class MembershipError(AuthorisationError): super().__init__(f"{type(self).__name__}: {error_description}.") +def db_row_to_group(row: sqlite3.Row) -> Group: + """Convert a database row into a group.""" + return Group(UUID(row["group_id"]), + row["group_name"], + json.loads(row["group_metadata"])) + + def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]: """Returns all the groups that a member belongs to""" query = ( diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py index 401be00..920f504 100644 --- a/gn_auth/auth/authorisation/resources/groups/views.py +++ b/gn_auth/auth/authorisation/resources/groups/views.py @@ -48,7 +48,9 @@ def create_group(): with require_oauth.acquire("profile group") as the_token: group_name=request_json().get("group_name", "").strip() if not bool(group_name): - raise GroupCreationError("Could not create the group.") + raise GroupCreationError( + "Could not create the group. Invalid Group name provided was " + f"`{group_name}`") db_uri = current_app.config["AUTH_DB"] with db.connection(db_uri) as conn: diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py index 2eda72b..494fde9 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 ( @@ -45,7 +46,7 @@ from .models import ( 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 +from .groups.models import Group resources = Blueprint("resources", __name__) @@ -265,7 +266,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 +293,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 @@ -439,6 +440,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 +500,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/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py index 8ca1e51..85aeb50 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, @@ -62,7 +60,8 @@ _FORM_GRANT_TYPES_ = ({ @admin.before_request def update_expires(): """Update session expiration.""" - if session.session_info() and not session.update_expiry(): + if (session.session_info() and not session.update_expiry( + int(app.config.get("SESSION_EXPIRY_MINUTES", 10)))): flash("Session has expired. Logging out...", "alert-warning") session.clear_session_info() return redirect(url_for("oauth2.admin.login")) @@ -96,7 +95,8 @@ def login(): session.update_session_info( user=asdict(user), expires=( - datetime.now(tz=timezone.utc) + timedelta(minutes=10))) + datetime.now(tz=timezone.utc) + timedelta(minutes=int( + app.config.get("SESSION_EXPIRY_MINUTES", 10))))) return redirect(url_for(next_uri)) raise NotFoundError(error_message) except NotFoundError as _nfe: @@ -176,6 +176,9 @@ def check_register_client_form(form): "scope[]", "You need to select at least one scope option."),) + if not uri_valid(form.get("client_jwk_uri", "")): + errors = errors + ("The provided client's public JWKs URI is invalid.",) + errors = tuple(item for item in errors if item is not None) if bool(errors): raise RegisterClientError(errors) @@ -223,7 +226,8 @@ def register_client(): "default_redirect_uri": default_redirect_uri, "redirect_uris": [default_redirect_uri] + form.get("other_redirect_uri", "").split(), "response_type": __response_types__(tuple(grant_types)), - "scope": form.getlist("scope[]") + "scope": form.getlist("scope[]"), + "public-jwks-uri": form.get("client_jwk_uri", "") }, user = with_db_connection(partial( user_by_id, user_id=uuid.UUID(form["user"]))) @@ -260,108 +264,6 @@ def view_client(client_id: uuid.UUID): scope=app.config["OAUTH2_SCOPE"], granttypes=_FORM_GRANT_TYPES_) -@admin.route("/register-client-public-key", methods=["POST"]) -@is_admin -def register_client_public_key(): - """Register a client's SSL key""" - form = request.form - admin_dashboard_uri = redirect(url_for("oauth2.admin.dashboard")) - view_client_uri = redirect(url_for("oauth2.admin.view_client", - client_id=form["client_id"])) - if not bool(form.get("client_id")): - flash("No client selected.", "alert-danger") - return admin_dashboard_uri - - try: - _client = with_db_connection(partial( - oauth2_client, client_id=uuid.UUID(form["client_id"]))) - if _client.is_nothing(): - raise ValueError("No such client.") - _client = _client.value - except ValueError: - flash("Invalid client ID provided.", "alert-danger") - return admin_dashboard_uri - try: - _key = JsonWebKey.import_key(form["client_ssl_key"].strip()) - except ValueError: - flash("Invalid key provided!", "alert-danger") - return view_client_uri - - keypath = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"]).joinpath( - f"{_key.thumbprint()}.pem") - if not keypath.exists(): - with open(keypath, mode="w", encoding="utf8") as _kpth: - _kpth.write(form["client_ssl_key"]) - - with_db_connection(partial(save_client, the_client=OAuth2Client( - client_id=_client.client_id, - client_secret=_client.client_secret, - client_id_issued_at=_client.client_id_issued_at, - client_secret_expires_at=_client.client_secret_expires_at, - client_metadata={ - **_client.client_metadata, - "public_keys": list(set( - _client.client_metadata.get("public_keys", []) + - [str(keypath)]))}, - user=_client.user))) - flash("Client key successfully registered.", "alert-success") - return view_client_uri - - -@admin.route("/delete-client-public-key", methods=["POST"]) -@is_admin -def delete_client_public_key(): - """Delete a client's SSL key""" - form = request.form - admin_dashboard_uri = redirect(url_for("oauth2.admin.dashboard")) - view_client_uri = redirect(url_for("oauth2.admin.view_client", - client_id=form["client_id"])) - if not bool(form.get("client_id")): - flash("No client selected.", "alert-danger") - return admin_dashboard_uri - - try: - _client = with_db_connection(partial( - oauth2_client, client_id=uuid.UUID(form["client_id"]))) - if _client.is_nothing(): - raise ValueError("No such client.") - _client = _client.value - except ValueError: - flash("Invalid client ID provided.", "alert-danger") - return admin_dashboard_uri - - if form.get("ssl_key", None) is None: - flash("The key must be provided.", "alert-danger") - return view_client_uri - - try: - def find_by_kid(keyset: KeySet, kid: str) -> JsonWebKey: - for key in keyset.keys: - if key.thumbprint() == kid: - return key - raise ValueError('Invalid JSON Web Key Set') - _key = find_by_kid(_client.jwks, form.get("ssl_key")) - except ValueError: - flash("Could not delete: No such public key.", "alert-danger") - return view_client_uri - - _keys = (_key for _key in _client.jwks.keys - if _key.thumbprint() != form["ssl_key"]) - _keysdir = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"]) - with_db_connection(partial(save_client, the_client=OAuth2Client( - client_id=_client.client_id, - client_secret=_client.client_secret, - client_id_issued_at=_client.client_id_issued_at, - client_secret_expires_at=_client.client_secret_expires_at, - client_metadata={ - **_client.client_metadata, - "public_keys": list(set( - _keysdir.joinpath(f"{_key.thumbprint()}.pem") - for _key in _keys))}, - user=_client.user))) - flash("Key deleted.", "alert-success") - return view_client_uri - @admin.route("/edit-client", methods=["POST"]) @is_admin @@ -389,7 +291,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, diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py index b4a24f3..f0a7fa2 100644 --- a/gn_auth/auth/authorisation/users/collections/models.py +++ b/gn_auth/auth/authorisation/users/collections/models.py @@ -205,8 +205,10 @@ def add_traits(rconn: Redis, mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id) __raise_if_not_single_collection__(user, collection_id, mod_col) new_members = tuple(set(tuple(mod_col[0]["members"]) + traits)) + now = datetime.utcnow() new_coll = { **mod_col[0], + "changed": now, "members": new_members, "num_members": len(new_members) } @@ -233,8 +235,10 @@ def remove_traits(rconn: Redis, __raise_if_not_single_collection__(user, collection_id, mod_col) new_members = tuple( trait for trait in mod_col[0]["members"] if trait not in traits) + now = datetime.utcnow() new_coll = { **mod_col[0], + "changed": now, "members": new_members, "num_members": len(new_members) } diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py index eeae91d..f619c3d 100644 --- a/gn_auth/auth/authorisation/users/collections/views.py +++ b/gn_auth/auth/authorisation/users/collections/views.py @@ -113,6 +113,7 @@ def import_anonymous() -> Response: anon_id = UUID(request.json.get("anon_id"))#type: ignore[union-attr] anon_colls = user_collections(redisconn, User( anon_id, "anon@ymous.user", "Anonymous User")) + anon_colls = tuple(coll for coll in anon_colls if coll['num_members'] > 0) save_collections( redisconn, token.user, diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py index 57bc564..8ac1a68 100644 --- a/gn_auth/auth/authorisation/users/masquerade/models.py +++ b/gn_auth/auth/authorisation/users/masquerade/models.py @@ -1,13 +1,16 @@ """Functions for handling masquerade.""" -from uuid import uuid4 +import uuid from functools import wraps from datetime import datetime +from authlib.jose import jwt from flask import current_app as app from gn_auth.auth.errors import ForbiddenAccess +from gn_auth.auth.jwks import newest_jwk_with_rotation, jwks_directory + from ...roles.models import user_roles from ....db import sqlite3 as db from ....authentication.users import User @@ -31,9 +34,13 @@ def can_masquerade(func): conn = kwargs["conn"] token = kwargs["original_token"] - masq_privs = [priv for role in user_roles(conn, token.user) - for priv in role.privileges - if priv.privilege_id == "system:user:masquerade"] + masq_privs = [] + for roles in user_roles(conn, token.user): + for role in roles["roles"]: + privileges = [p for p in role.privileges + if p.privilege_id == "system:user:masquerade"] + masq_privs.extend(privileges) + if len(masq_privs) == 0: raise ForbiddenAccess( "You do not have the ability to masquerade as another user.") @@ -52,8 +59,14 @@ def masquerade_as( user=masqueradee, expires_in=__FIVE_HOURS__, include_refresh_token=True) + + _jwt = jwt.decode( + original_token.access_token, + newest_jwk_with_rotation( + jwks_directory(app), + int(app.config["JWKS_ROTATION_AGE_DAYS"]))) new_token = OAuth2Token( - token_id=uuid4(), + token_id=uuid.UUID(_jwt["jti"]), client=original_token.client, token_type=token_details["token_type"], access_token=token_details["access_token"], diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py index 276859a..68f19ee 100644 --- a/gn_auth/auth/authorisation/users/masquerade/views.py +++ b/gn_auth/auth/authorisation/users/masquerade/views.py @@ -33,13 +33,13 @@ def masquerade() -> Response: return new_token def __dump_token__(tok): return { - key: value for key, value in (tok._asdict().items()) + key: value for key, value in asdict(tok).items() if key in ("access_token", "refresh_token", "expires_in", "token_type") } return jsonify({ "original": { - "user": token.user._asdict(), + "user": asdict(token.user), "token": __dump_token__(token) }, "masquerade_as": { diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py index 8135ed3..7adcd06 100644 --- a/gn_auth/auth/authorisation/users/views.py +++ b/gn_auth/auth/authorisation/users/views.py @@ -1,12 +1,13 @@ """User authorisation endpoints.""" +import uuid import sqlite3 import secrets -import datetime import traceback from typing import Any from functools import partial from dataclasses import asdict from urllib.parse import urljoin +from datetime import datetime, timedelta from email.headerregistry import Address from email_validator import validate_email, EmailNotValidError from flask import ( @@ -123,7 +124,7 @@ def send_verification_email( """Send an email verification message.""" subject="GeneNetwork: Please Verify Your Email" verification_code = secrets.token_urlsafe(64) - generated = datetime.datetime.now() + generated = datetime.now() expiration_minutes = 15 def __render__(template): return render_template(template, @@ -148,12 +149,13 @@ def send_verification_email( "generated": int(generated.timestamp()), "expires": int( (generated + - datetime.timedelta( + timedelta( minutes=expiration_minutes)).timestamp()) }) - send_message(smtp_user=current_app.config["SMTP_USER"], - smtp_passwd=current_app.config["SMTP_PASSWORD"], + send_message(smtp_user=current_app.config.get("SMTP_USER", ""), + smtp_passwd=current_app.config.get("SMTP_PASSWORD", ""), message=build_email_message( + from_address=current_app.config["EMAIL_ADDRESS"], to_addresses=(user_address(user),), subject=subject, txtmessage=__render__("emails/verify-email.txt"), @@ -187,11 +189,11 @@ def register_user() -> Response: redirect_uri=form["redirect_uri"]) return jsonify(asdict(user)) except sqlite3.IntegrityError as sq3ie: - current_app.logger.debug(traceback.format_exc()) + current_app.logger.error(traceback.format_exc()) raise UserRegistrationError( "A user with that email already exists") from sq3ie except EmailNotValidError as enve: - current_app.logger.debug(traceback.format_exc()) + current_app.logger.error(traceback.format_exc()) raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve raise Exception( @@ -235,11 +237,12 @@ def verify_user(): return loginuri results = results[0] - if (datetime.datetime.fromtimestamp( - int(results["expires"])) < datetime.datetime.now()): + if (datetime.fromtimestamp( + int(results["expires"])) < datetime.now()): delete_verification_code(cursor, verificationcode) flash("Invalid verification code: code has expired.", "alert-danger") + return loginuri # Code is good! delete_verification_code(cursor, verificationcode) @@ -310,15 +313,29 @@ def list_all_users() -> Response: @users.route("/handle-unverified", methods=["POST"]) def handle_unverified(): """Handle case where user tries to login but is unverified""" - form = request_json() + email = request.args["email"] # TODO: Maybe have a GN2_URI setting here? # or pass the client_id here? + with (db.connection(current_app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + cursor.execute( + "DELETE FROM user_verification_codes WHERE expires <= ?", + (int(datetime.now().timestamp()),)) + cursor.execute( + "SELECT u.user_id, u.email, uvc.* FROM users AS u " + "INNER JOIN user_verification_codes AS uvc " + "ON u.user_id=uvc.user_id " + "WHERE u.email=?", + (email,)) + token_found = bool(cursor.fetchone()) + return render_template( "users/unverified-user.html", - email=form.get("user:email"), + email=email, response_type=request.args["response_type"], client_id=request.args["client_id"], - redirect_uri=request.args["redirect_uri"]) + redirect_uri=request.args["redirect_uri"], + token_found=token_found) @users.route("/send-verification", methods=["POST"]) def send_verification_code(): @@ -350,3 +367,140 @@ def send_verification_code(): }) resp.code = 400 return resp + + +def send_forgot_password_email( + conn, + user: User, + client_id: uuid.UUID, + redirect_uri: str, + response_type: str +): + """Send the 'forgot-password' email.""" + subject="GeneNetwork: Change Your Password" + token = secrets.token_urlsafe(64) + generated = datetime.now() + expiration_minutes = 15 + def __render__(template): + return render_template(template, + subject=subject, + forgot_password_uri=urljoin( + request.url, + url_for("oauth2.users.change_password", + forgot_password_token=token, + client_id=client_id, + redirect_uri=redirect_uri, + response_type=response_type)), + expiration_minutes=expiration_minutes) + + with db.cursor(conn) as cursor: + cursor.execute( + ("INSERT OR REPLACE INTO " + "forgot_password_tokens(user_id, token, generated, expires) " + "VALUES (:user_id, :token, :generated, :expires)"), + { + "user_id": str(user.user_id), + "token": token, + "generated": int(generated.timestamp()), + "expires": int( + (generated + + timedelta( + minutes=expiration_minutes)).timestamp()) + }) + + send_message(smtp_user=current_app.config["SMTP_USER"], + smtp_passwd=current_app.config["SMTP_PASSWORD"], + message=build_email_message( + from_address=current_app.config["EMAIL_ADDRESS"], + to_addresses=(user_address(user),), + subject=subject, + txtmessage=__render__("emails/forgot-password.txt"), + htmlmessage=__render__("emails/forgot-password.html")), + host=current_app.config["SMTP_HOST"], + port=current_app.config["SMTP_PORT"]) + + +@users.route("/forgot-password", methods=["GET", "POST"]) +def forgot_password(): + """Enable user to request password change.""" + if request.method == "GET": + return render_template("users/forgot-password.html", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"]) + + form = request.form + email = form.get("email", "").strip() + if not bool(email): + flash("You MUST provide an email.", "alert-danger") + return redirect(url_for("oauth2.users.forgot_password")) + + with db.connection(current_app.config["AUTH_DB"]) as conn: + user = user_by_email(conn, form["email"]) + if not bool(user): + flash("We could not find an account with that email.", + "alert-danger") + return redirect(url_for("oauth2.users.forgot_password")) + + send_forgot_password_email(conn, + user, + request.args["client_id"], + request.args["redirect_uri"], + request.args["response_type"]) + return render_template("users/forgot-password-token-send-success.html", + email=form["email"]) + + +@users.route("/change-password/<forgot_password_token>", methods=["GET", "POST"]) +def change_password(forgot_password_token): + """Enable user to perform password change.""" + login_page = redirect(url_for("oauth2.auth.authorise", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"])) + with (db.connection(current_app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + cursor.execute("DELETE FROM forgot_password_tokens WHERE expires<=?", + (int(datetime.now().timestamp()),)) + cursor.execute( + "SELECT fpt.*, u.email FROM forgot_password_tokens AS fpt " + "INNER JOIN users AS u ON fpt.user_id=u.user_id WHERE token=?", + (forgot_password_token,)) + token = cursor.fetchone() + if request.method == "GET": + if bool(token): + return render_template( + "users/change-password.html", + email=token["email"], + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"], + forgot_password_token=forgot_password_token) + flash("Invalid Token: We cannot change your password!", + "alert-danger") + return login_page + + password = request.form["password"] + confirm_password = request.form["confirm-password"] + change_password_page = redirect(url_for( + "oauth2.users.change_password", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"], + forgot_password_token=forgot_password_token)) + if bool(password) and bool(confirm_password): + if password == confirm_password: + _user, _hashed_password = set_user_password( + cursor, user_by_email(conn, token["email"]), password) + cursor.execute( + "DELETE FROM forgot_password_tokens WHERE token=?", + (forgot_password_token,)) + flash("Password changed successfully!", "alert-success") + return login_page + + flash("Passwords do not match!", "alert-danger") + return change_password_page + + flash("Both the password and its confirmation MUST be provided!", + "alert-danger") + return change_password_page diff --git a/gn_auth/auth/jwks.py b/gn_auth/auth/jwks.py new file mode 100644 index 0000000..7381000 --- /dev/null +++ b/gn_auth/auth/jwks.py @@ -0,0 +1,86 @@ +"""Utilities dealing with JSON Web Keys (JWK)""" +import os +from pathlib import Path +from typing import Any, Union +from datetime import datetime, timedelta + +from flask import Flask +from authlib.jose import JsonWebKey +from pymonad.either import Left, Right, Either + +def jwks_directory(app: Flask) -> Path: + """Compute the directory where the JWKs are stored.""" + appsecretsdir = Path(app.config["GN_AUTH_SECRETS"]).parent + if appsecretsdir.exists() and appsecretsdir.is_dir(): + jwksdir = Path(appsecretsdir, "jwks/") + if not jwksdir.exists(): + jwksdir.mkdir() + return jwksdir + raise ValueError( + "The `appsecretsdir` value should be a directory that actually exists.") + + +def generate_and_save_private_key( + storagedir: Path, + kty: str = "RSA", + crv_or_size: Union[str, int] = 2048, + options: tuple[tuple[str, Any]] = (("iat", datetime.now().timestamp()),) +) -> JsonWebKey: + """Generate a private key and save to `storagedir`.""" + privatejwk = JsonWebKey.generate_key( + kty, crv_or_size, dict(options), is_private=True) + keyname = f"{privatejwk.thumbprint()}.private.pem" + with open(Path(storagedir, keyname), "wb") as pemfile: + pemfile.write(privatejwk.as_pem(is_private=True)) + + return privatejwk + + +def pem_to_jwk(filepath: Path) -> JsonWebKey: + """Parse a PEM file into a JWK object.""" + with open(filepath, "rb") as pemfile: + return JsonWebKey.import_key(pemfile.read()) + + +def __sorted_jwks_paths__(storagedir: Path) -> tuple[tuple[float, Path], ...]: + """A sorted list of the JWK file paths with their creation timestamps.""" + return tuple(sorted(((os.stat(keypath).st_ctime, keypath) + for keypath in (Path(storagedir, keyfile) + for keyfile in os.listdir(storagedir) + if keyfile.endswith(".pem"))), + key=lambda tpl: tpl[0])) + + +def list_jwks(storagedir: Path) -> tuple[JsonWebKey, ...]: + """ + List all the JWKs in a particular directory in the order they were created. + """ + return tuple(pem_to_jwk(keypath) for ctime,keypath in + __sorted_jwks_paths__(storagedir)) + + +def newest_jwk(storagedir: Path) -> Either: + """ + Return an Either monad with the newest JWK or a message if none exists. + """ + existingkeys = __sorted_jwks_paths__(storagedir) + if len(existingkeys) > 0: + return Right(pem_to_jwk(existingkeys[-1][1])) + return Left("No JWKs exist") + + +def newest_jwk_with_rotation(jwksdir: Path, keyage: int) -> JsonWebKey: + """ + Retrieve the latests JWK, creating a new one if older than `keyage` days. + """ + def newer_than_days(jwkey): + filestat = os.stat(Path( + jwksdir, f"{jwkey.as_dict()['kid']}.private.pem")) + oldesttimeallowed = (datetime.now() - timedelta(days=keyage)) + if filestat.st_ctime < (oldesttimeallowed.timestamp()): + return Left("JWK is too old!") + return jwkey + + return newest_jwk(jwksdir).then(newer_than_days).either( + lambda _errmsg: generate_and_save_private_key(jwksdir), + lambda key: key) diff --git a/gn_auth/auth/requests.py b/gn_auth/auth/requests.py index 6301029..00e9b35 100644 --- a/gn_auth/auth/requests.py +++ b/gn_auth/auth/requests.py @@ -3,4 +3,4 @@ from flask import request def request_json() -> dict: """Retrieve the JSON sent in a request.""" - return request.json or {} + return request.json or dict(request.form) or {} diff --git a/gn_auth/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/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..2a78be3 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" @@ -29,9 +32,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 +42,4 @@ SMTP_PORT = 587 SMTP_TIMEOUT = 200 # seconds SMTP_USER = "no-reply@genenetwork.org" SMTP_PASSWORD = "asecrettoken" +EMAIL_ADDRESS = "no-reply@uthsc.edu" diff --git a/gn_auth/smtp.py b/gn_auth/smtp.py index 0c9f878..2f0e7f4 100644 --- a/gn_auth/smtp.py +++ b/gn_auth/smtp.py @@ -17,17 +17,17 @@ def __read_mime__(filepath) -> dict: def build_email_message(# pylint: disable=[too-many-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) @@ -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/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/emails/forgot-password.html b/gn_auth/templates/emails/forgot-password.html new file mode 100644 index 0000000..e40ebb8 --- /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> minutes. + </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..55a4b13 --- /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 {{expiration_minutes}} 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/oauth2/authorise-user.html b/gn_auth/templates/oauth2/authorise-user.html index d69bdd4..2ef22af 100644 --- a/gn_auth/templates/oauth2/authorise-user.html +++ b/gn_auth/templates/oauth2/authorise-user.html @@ -29,7 +29,17 @@ <input type="password" name="user:password" id="user:password" required="required" class="form-control" /> </div> - - <input type="submit" value="authorise" class="btn btn-primary" /> + + <div class="form-group"> + <input type="submit" value="authorise" 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> </form> {%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..bb8abd2 100644 --- a/gn_auth/wsgi.py +++ b/gn_auth/wsgi.py @@ -22,7 +22,6 @@ 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: @@ -53,7 +52,6 @@ def setup_loggers() -> Callable[[Flask], None]: "SERVER_SOFTWARE", "").split('/') return gunicorn_loggers if bool(software) else dev_loggers -# app = create_app() app = create_app(setup_logging=setup_loggers()) ##### BEGIN: CLI Commands ##### @@ -67,8 +65,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 +95,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 +125,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 +155,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/scripts/migrate_existing_data.py b/scripts/assign_data_to_default_admin.py index 198d37d..0ae209a 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 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..3df123d --- /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 pymonad.maybe import Just, Maybe, Nothing +from pymonad.tools import monad_from_none_or_value + +from gn_auth.auth.db import mariadb as biodb +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..5db7ea8 100644 --- a/scripts/link_inbredsets.py +++ b/scripts/link_inbredsets.py @@ -11,7 +11,8 @@ 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/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" |