aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.guix/modules/gn-auth.scm3
-rw-r--r--MANIFEST.in2
-rw-r--r--README.md39
-rw-r--r--gn_auth/__init__.py19
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/introspection.py1
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/revocation.py1
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py2
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py15
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py12
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py38
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py54
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py64
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py27
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py8
-rw-r--r--gn_auth/auth/authorisation/resources/groups/views.py4
-rw-r--r--gn_auth/auth/authorisation/resources/views.py18
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py119
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py4
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py1
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/models.py23
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/views.py4
-rw-r--r--gn_auth/auth/authorisation/users/views.py178
-rw-r--r--gn_auth/auth/jwks.py86
-rw-r--r--gn_auth/auth/requests.py2
-rw-r--r--gn_auth/errors.py8
-rw-r--r--gn_auth/misc_views.py11
-rw-r--r--gn_auth/session.py4
-rw-r--r--gn_auth/settings.py10
-rw-r--r--gn_auth/smtp.py15
-rw-r--r--gn_auth/static/images/CITGLogo.pngbin0 -> 11962 bytes
-rw-r--r--gn_auth/templates/admin/register-client.html109
-rw-r--r--gn_auth/templates/admin/view-oauth2-client.html136
-rw-r--r--gn_auth/templates/emails/forgot-password.html38
-rw-r--r--gn_auth/templates/emails/forgot-password.txt12
-rw-r--r--gn_auth/templates/oauth2/authorise-user.html14
-rw-r--r--gn_auth/templates/users/change-password.html52
-rw-r--r--gn_auth/templates/users/forgot-password-token-send-success.html22
-rw-r--r--gn_auth/templates/users/forgot-password.html38
-rw-r--r--gn_auth/templates/users/unverified-user.html148
-rw-r--r--gn_auth/wsgi.py47
-rw-r--r--migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py26
-rw-r--r--scripts/assign_data_to_default_admin.py (renamed from scripts/migrate_existing_data.py)4
-rw-r--r--scripts/batch_assign_data_to_default_admin.py87
-rw-r--r--scripts/link_inbredsets.py3
-rw-r--r--tests/unit/conftest.py13
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
diff --git a/README.md b/README.md
index f4f0d48..d72ed81 100644
--- a/README.md
+++ b/README.md
@@ -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
new file mode 100644
index 0000000..ae99fed
--- /dev/null
+++ b/gn_auth/static/images/CITGLogo.png
Binary files differ
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 &mdash; especially if you already have an account,
- and have been using it just fine &mdash; 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"