aboutsummaryrefslogtreecommitdiff
path: root/gn_auth
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth')
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py57
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py10
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py20
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py11
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py5
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py100
-rw-r--r--gn_auth/auth/authorisation/data/genotypes.py2
-rw-r--r--gn_auth/auth/authorisation/data/mrna.py2
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/models.py2
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py22
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/models.py2
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/views.py11
-rw-r--r--gn_auth/auth/authorisation/resources/models.py2
-rw-r--r--gn_auth/auth/authorisation/resources/views.py2
-rw-r--r--gn_auth/auth/authorisation/roles/models.py2
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/models.py2
-rw-r--r--gn_auth/auth/authorisation/users/views.py35
-rw-r--r--gn_auth/settings.py4
-rw-r--r--gn_auth/smtp.py4
-rw-r--r--gn_auth/templates/base.html4
-rw-r--r--gn_auth/templates/emails/forgot-password.html2
-rw-r--r--gn_auth/templates/emails/forgot-password.txt2
-rw-r--r--gn_auth/templates/emails/verify-email.html2
-rw-r--r--gn_auth/templates/emails/verify-email.txt2
-rw-r--r--gn_auth/templates/oauth2/authorise-user.html86
25 files changed, 263 insertions, 130 deletions
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 27783ac..c802091 100644
--- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
@@ -1,8 +1,12 @@
"""JWT as Authorisation Grant"""
import uuid
+import time
+from typing import Optional
from flask import current_app as app
+from authlib.jose import jwt
+from authlib.common.encoding import to_native
from authlib.common.security import generate_token
from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant
from authlib.oauth2.rfc7523.token import (
@@ -10,7 +14,8 @@ from authlib.oauth2.rfc7523.token import (
from gn_auth.debug import __pk__
from gn_auth.auth.db.sqlite3 import with_db_connection
-from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.users import User, user_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client
class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
@@ -20,12 +25,24 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
DEFAULT_EXPIRES_IN = 300
- def get_token_data(#pylint: disable=[too-many-arguments]
+ def get_token_data(#pylint: disable=[too-many-arguments, too-many-positional-arguments]
self, grant_type, client, expires_in=None, user=None, scope=None
):
"""Post process data to prevent JSON serialization problems."""
- tokendata = super().get_token_data(
- grant_type, client, expires_in, user, scope)
+ issued_at = int(time.time())
+ tokendata = {
+ "scope": self.get_allowed_scope(client, scope),
+ "grant_type": grant_type,
+ "iat": issued_at,
+ "client_id": client.get_client_id()
+ }
+ if isinstance(expires_in, int) and expires_in > 0:
+ tokendata["exp"] = issued_at + expires_in
+ if self.issuer:
+ tokendata["iss"] = self.issuer
+ if user:
+ tokendata["sub"] = self.get_sub_value(user)
+
return {
**{
key: str(value) if key.endswith("_id") else value
@@ -36,8 +53,38 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
"oauth2_client_id": str(client.client_id)
}
+ def generate(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ self,
+ grant_type: str,
+ client: OAuth2Client,
+ user: Optional[User] = None,
+ scope: Optional[str] = None,
+ expires_in: Optional[int] = None
+ ) -> dict:
+ """Generate a bearer token for OAuth 2.0 authorization token endpoint.
+
+ :param client: the client that making the request.
+ :param grant_type: current requested grant_type.
+ :param user: current authorized user.
+ :param expires_in: if provided, use this value as expires_in.
+ :param scope: current requested scope.
+ :return: Token dict
+ """
+
+ token_data = self.get_token_data(grant_type, client, expires_in, user, scope)
+ access_token = jwt.encode({"alg": self.alg}, token_data, key=self.secret_key, check=False)
+ token = {
+ "token_type": "Bearer",
+ "access_token": to_native(access_token)
+ }
+ if expires_in:
+ token["expires_in"] = expires_in
+ if scope:
+ token["scope"] = scope
+ return token
+
- def __call__(# pylint: disable=[too-many-arguments]
+ def __call__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
self, grant_type, client, user=None, scope=None, expires_in=None,
include_refresh_token=True
):
diff --git a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
index fd6804d..f897d89 100644
--- a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
@@ -34,18 +34,18 @@ class RefreshTokenGrant(grants.RefreshTokenGrant):
else Nothing)
).maybe(None, lambda _tok: _tok)
- def authenticate_user(self, credential):
+ def authenticate_user(self, refresh_token):
"""Check that user is valid for given token."""
with connection(app.config["AUTH_DB"]) as conn:
try:
- return user_by_id(conn, credential.user.user_id)
+ return user_by_id(conn, refresh_token.user.user_id)
except NotFoundError as _nfe:
return None
return None
- def revoke_old_credential(self, credential):
+ def revoke_old_credential(self, refresh_token):
"""Revoke any old refresh token after issuing new refresh token."""
with connection(app.config["AUTH_DB"]) as conn:
- if credential.parent_of is not None:
- revoke_refresh_token(conn, credential)
+ if refresh_token.parent_of is not None:
+ revoke_refresh_token(conn, refresh_token)
diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
index cca75f4..71769e1 100644
--- a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
+++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
@@ -1,5 +1,7 @@
"""Implement model for JWTBearerToken"""
import uuid
+import time
+from typing import Optional
from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken
@@ -28,3 +30,21 @@ class JWTBearerToken(_JWTBearerToken):
def check_client(self, client):
"""Check that the client is right."""
return self.client.get_client_id() == client.get_client_id()
+
+
+ def get_expires_in(self) -> Optional[int]:
+ """Return the number of seconds the token is valid for since issue.
+
+ If `None`, the token never expires."""
+ if "exp" in self:
+ return self['exp'] - self['iat']
+ return None
+
+
+ def is_expired(self):
+ """Check whether the token is expired.
+
+ If there is no 'exp' member, assume this token will never expire."""
+ if "exp" in self:
+ return self["exp"] < time.time()
+ return False
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index 79b6e53..1639e2e 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -1,6 +1,5 @@
"""OAuth2 Client model."""
import json
-import logging
import datetime
from uuid import UUID
from functools import cached_property
@@ -8,6 +7,7 @@ from dataclasses import asdict, dataclass
from typing import Any, Sequence, Optional
import requests
+from flask import current_app as app
from requests.exceptions import JSONDecodeError
from authlib.jose import KeySet, JsonWebKey
from authlib.oauth2.rfc6749 import ClientMixin
@@ -65,7 +65,7 @@ class OAuth2Client(ClientMixin):
jwksuri = self.client_metadata.get("public-jwks-uri")
__pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri)
if not bool(jwksuri):
- logging.debug("No Public JWKs URI set for client!")
+ app.logger.debug("No Public JWKs URI set for client!")
return KeySet([])
try:
## IMPORTANT: This can cause a deadlock if the client is working in
@@ -74,15 +74,16 @@ class OAuth2Client(ClientMixin):
return KeySet([JsonWebKey.import_key(key)
for key in requests.get(
jwksuri,
+ timeout=300,
allow_redirects=True).json()["jwks"]])
except requests.ConnectionError as _connerr:
- logging.debug(
+ app.logger.debug(
"Could not connect to provided URI: %s", jwksuri, exc_info=True)
except JSONDecodeError as _jsonerr:
- logging.debug(
+ app.logger.debug(
"Could not convert response to JSON", exc_info=True)
except Exception as _exc:# pylint: disable=[broad-except]
- logging.debug(
+ app.logger.debug(
"Error retrieving the JWKs for the client.", exc_info=True)
return KeySet([])
diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py
index 9c885e2..8ecf923 100644
--- a/gn_auth/auth/authentication/oauth2/resource_server.py
+++ b/gn_auth/auth/authentication/oauth2/resource_server.py
@@ -43,6 +43,11 @@ class JWTBearerTokenValidator(_JWTBearerTokenValidator):
self._last_jwks_update = datetime.now(tz=timezone.utc)
self._refresh_frequency = timedelta(hours=int(
extra_attributes.get("jwt_refresh_frequency_hours", 6)))
+ self.claims_options = {
+ 'exp': {'essential': False},
+ 'client_id': {'essential': True},
+ 'grant_type': {'essential': True},
+ }
def __refresh_jwks__(self):
now = datetime.now(tz=timezone.utc)
diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py
index a8109b7..8ac5106 100644
--- a/gn_auth/auth/authentication/oauth2/server.py
+++ b/gn_auth/auth/authentication/oauth2/server.py
@@ -3,12 +3,12 @@ import uuid
from typing import Callable
from datetime import datetime
-from flask import Flask, current_app
-from authlib.jose import jwt, KeySet
+from flask import Flask, current_app, request as flask_request
+from authlib.jose import KeySet
+from authlib.oauth2.rfc6749 import OAuth2Request
from authlib.oauth2.rfc6749.errors import InvalidClientError
from authlib.integrations.flask_oauth2 import AuthorizationServer
-from authlib.oauth2.rfc6749 import OAuth2Request
-from authlib.integrations.flask_helpers import create_oauth_request
+from authlib.integrations.flask_oauth2.requests import FlaskOAuth2Request
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.jwks import (
@@ -16,13 +16,9 @@ from gn_auth.auth.jwks import (
jwks_directory,
newest_jwk_with_rotation)
+from .models.jwt_bearer_token import JWTBearerToken
from .models.oauth2client import client as fetch_client
from .models.oauth2token import OAuth2Token, save_token
-from .models.jwtrefreshtoken import (
- JWTRefreshToken,
- link_child_token,
- save_refresh_token,
- load_refresh_token)
from .grants.password_grant import PasswordGrant
from .grants.refresh_token_grant import RefreshTokenGrant
@@ -34,6 +30,8 @@ from .endpoints.introspection import IntrospectionEndpoint
from .resource_server import require_oauth, JWTBearerTokenValidator
+_TWO_HOURS_ = 2 * 60 * 60
+
def create_query_client_func() -> Callable:
"""Create the function that loads the client."""
@@ -50,54 +48,32 @@ def create_query_client_func() -> Callable:
return __query_client__
-def create_save_token_func(token_model: type, app: Flask) -> Callable:
+def create_save_token_func(token_model: type) -> Callable:
"""Create the function that saves the token."""
+ def __ignore_token__(token, request):# pylint: disable=[unused-argument]
+ """Ignore the token: i.e. Do not save it."""
+
def __save_token__(token, request):
- _jwt = jwt.decode(
- token["access_token"],
- 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,
- user=request.user,
- **{
- "refresh_token": None,
- "revoked": False,
- "issued_at": datetime.now(),
- **token
- })
with db.connection(current_app.config["AUTH_DB"]) as conn:
- save_token(conn, _token)
- old_refresh_token = load_refresh_token(
+ save_token(
conn,
- request.form.get("refresh_token", "nosuchtoken")
- )
- new_refresh_token = JWTRefreshToken(
- token=_token.refresh_token,
+ token_model(
+ **token,
+ token_id=uuid.uuid4(),
client=request.client,
user=request.user,
- issued_with=uuid.UUID(_jwt["jti"]),
- issued_at=datetime.fromtimestamp(_jwt["iat"]),
- expires=datetime.fromtimestamp(
- old_refresh_token.then(
- lambda _tok: _tok.expires.timestamp()
- ).maybe((int(_jwt["iat"]) +
- RefreshTokenGrant.DEFAULT_EXPIRES_IN),
- lambda _expires: _expires)),
- scope=_token.get_scope(),
+ issued_at=datetime.now(),
revoked=False,
- parent_of=None)
- save_refresh_token(conn, new_refresh_token)
- old_refresh_token.then(lambda _tok: link_child_token(
- conn, _tok.token, new_refresh_token.token))
+ expires_in=_TWO_HOURS_))
- return __save_token__
+ return {
+ OAuth2Token: __save_token__,
+ JWTBearerToken: __ignore_token__
+ }[token_model]
def make_jwt_token_generator(app):
"""Make token generator function."""
- def __generator__(# pylint: disable=[too-many-arguments]
+ def __generator__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
grant_type,
client,
user=None,
@@ -106,15 +82,17 @@ def make_jwt_token_generator(app):
include_refresh_token=True
):
return JWTBearerTokenGenerator(
- newest_jwk_with_rotation(
+ secret_key=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)
+ int(app.config["JWKS_ROTATION_AGE_DAYS"])),
+ issuer=flask_request.host_url,
+ alg="RS256").__call__(
+ grant_type=grant_type,
+ client=client,
+ user=user,
+ scope=scope,
+ expires_in=expires_in,
+ include_refresh_token=include_refresh_token)
return __generator__
@@ -124,8 +102,16 @@ class JsonAuthorizationServer(AuthorizationServer):
def create_oauth2_request(self, request):
"""Create an OAuth2 Request from the flask request."""
- res = create_oauth_request(request, OAuth2Request, True)
- return res
+ match flask_request.headers.get("Content-Type"):
+ case "application/json":
+ req = OAuth2Request(flask_request.method,
+ flask_request.url,
+ flask_request.get_json(),
+ flask_request.headers)
+ case _:
+ req = FlaskOAuth2Request(flask_request)
+
+ return req
def setup_oauth2_server(app: Flask) -> None:
@@ -153,7 +139,7 @@ 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))
+ save_token=create_save_token_func(JWTBearerToken))
app.config["OAUTH2_SERVER"] = server
## Set up the token validators
diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py
index 7cae91a..ddb0add 100644
--- a/gn_auth/auth/authorisation/data/genotypes.py
+++ b/gn_auth/auth/authorisation/data/genotypes.py
@@ -22,7 +22,7 @@ def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_genotype_data(# pylint: disable=[too-many-arguments]
+def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py
index 82a0f82..0cc644e 100644
--- a/gn_auth/auth/authorisation/data/mrna.py
+++ b/gn_auth/auth/authorisation/data/mrna.py
@@ -22,7 +22,7 @@ def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_mrna_data(# pylint: disable=[too-many-arguments]
+def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
diff --git a/gn_auth/auth/authorisation/resources/genotypes/models.py b/gn_auth/auth/authorisation/resources/genotypes/models.py
index e8dca9b..464537e 100644
--- a/gn_auth/auth/authorisation/resources/genotypes/models.py
+++ b/gn_auth/auth/authorisation/resources/genotypes/models.py
@@ -68,7 +68,7 @@ def attach_resources_data(
return __attach_data__(cursor.fetchall(), resources)
-def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments]
+def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
cursor,
resource_id: uuid.UUID,
group_id: uuid.UUID,
diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index 3263e37..fa25594 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -8,6 +8,8 @@ from typing import Any, Sequence, Iterable, Optional
import sqlite3
from flask import g
from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.either import Left, Right, Either
+from pymonad.tools import monad_from_none_or_value
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, user_by_id
@@ -497,3 +499,23 @@ def add_resources_to_group(conn: db.DbConnection,
"group_id": str(group.group_id),
"resource_id": str(rsc.resource_id)
} for rsc in resources))
+
+
+def admin_group(conn: db.DbConnection) -> Either:
+ """Return a group where at least one system admin is a member."""
+ query = (
+ "SELECT DISTINCT g.group_id, g.group_name, g.group_metadata "
+ "FROM roles AS r INNER JOIN user_roles AS ur ON r.role_id=ur.role_id "
+ "INNER JOIN group_users AS gu ON ur.user_id=gu.user_id "
+ "INNER JOIN groups AS g ON gu.group_id=g.group_id "
+ "WHERE role_name='system-administrator'")
+ with db.cursor(conn) as cursor:
+ cursor.execute(query)
+ return monad_from_none_or_value(
+ Left("There is no group of which the system admininstrator is a "
+ "member."),
+ lambda row: Right(Group(
+ UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))),
+ cursor.fetchone())
diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py
index de1c18a..64d41e3 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/models.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/models.py
@@ -62,7 +62,7 @@ def assign_inbredset_group_owner_role(
return resource
-def link_data_to_resource(# pylint: disable=[too-many-arguments]
+def link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
cursor: sqlite3.Cursor,
resource_id: UUID,
species_id: int,
diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py
index b559105..40dd38d 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/views.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/views.py
@@ -7,7 +7,7 @@ from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.requests import request_json
from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
-from gn_auth.auth.authorisation.resources.groups.models import user_group
+from gn_auth.auth.authorisation.resources.groups.models import user_group, admin_group
from .models import (create_resource,
link_data_to_resource,
@@ -83,7 +83,14 @@ def create_population_resource():
return Right({"formdata": form, "group": usergroup})
- return user_group(conn, _token.user).then(
+ def __default_group_if_none__(group) -> Either:
+ if group.is_nothing():
+ return admin_group(conn)
+ return Right(group.value)
+
+ return __default_group_if_none__(
+ user_group(conn, _token.user)
+ ).then(
lambda group: __check_form__(request_json(), group)
).then(
lambda formdata: {
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index c1748f1..d136fec 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -39,7 +39,7 @@ from .phenotypes.models import (
@authorised_p(("group:resource:create-resource",),
error_description="Insufficient privileges to create a resource",
oauth2_scope="profile resource")
-def create_resource(# pylint: disable=[too-many-arguments]
+def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
cursor: sqlite3.Cursor,
resource_name: str,
resource_category: ResourceCategory,
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index 1c4104a..29ab3ed 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -137,7 +137,7 @@ def view_resource_data(resource_id: UUID) -> Response:
with require_oauth.acquire("profile group resource") as the_token:
db_uri = app.config["AUTH_DB"]
count_per_page = __safe_get_requests_count__("count_per_page")
- offset = (__safe_get_requests_page__("page") - 1)
+ offset = __safe_get_requests_page__("page") - 1
with db.connection(db_uri) as conn:
resource = resource_by_id(conn, the_token.user, resource_id)
return jsonify(resource_data(
diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py
index 2729b3b..6faeaca 100644
--- a/gn_auth/auth/authorisation/roles/models.py
+++ b/gn_auth/auth/authorisation/roles/models.py
@@ -271,7 +271,7 @@ def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]:
_roles = db_rows_to_roles(results)
if len(_roles) > 1:
- raise Exception("Data corruption: Expected a single role.")
+ raise Exception("Data corruption: Expected a single role.")# pylint: disable=[broad-exception-raised]
return _roles[0]
diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py
index a155899..5c11f34 100644
--- a/gn_auth/auth/authorisation/users/masquerade/models.py
+++ b/gn_auth/auth/authorisation/users/masquerade/models.py
@@ -20,7 +20,7 @@ from ....db import sqlite3 as db
from ....authentication.users import User
from ....authentication.oauth2.models.oauth2token import OAuth2Token
-__FIVE_HOURS__ = (60 * 60 * 5)
+__FIVE_HOURS__ = 60 * 60 * 5
def can_masquerade(func):
"""Security decorator."""
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 7adcd06..6183388 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -114,6 +114,29 @@ def user_address(user: User) -> Address:
"""Compute the `email.headerregistry.Address` from a `User`"""
return Address(display_name=user.name, addr_spec=user.email)
+
+def display_minutes_for_humans(minutes):
+ """Convert minutes into human-readable display."""
+ _week_ = 10080 # minutes
+ _day_ = 1440 # minutes
+ _remainder_ = minutes
+
+ _human_readable_ = ""
+ if _remainder_ >= _week_:
+ _weeks_ = _remainder_ // _week_
+ _remainder_ = _remainder_ % _week_
+ _human_readable_ += f"{_weeks_} week" + ("s" if _weeks_ > 1 else "")
+
+ if _remainder_ >= _day_:
+ _days_ = _remainder_ // _day_
+ _remainder_ = _remainder_ % _day_
+ _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_days_} day" + ("s" if _days_ > 1 else "")
+
+ if _remainder_ > 0:
+ _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_remainder_} minutes"
+
+ return _human_readable_
+
def send_verification_email(
conn,
user: User,
@@ -125,7 +148,7 @@ def send_verification_email(
subject="GeneNetwork: Please Verify Your Email"
verification_code = secrets.token_urlsafe(64)
generated = datetime.now()
- expiration_minutes = 15
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
def __render__(template):
return render_template(template,
subject=subject,
@@ -137,7 +160,8 @@ def send_verification_email(
client_id=client_id,
redirect_uri=redirect_uri,
verificationcode=verification_code)),
- expiration_minutes=expiration_minutes)
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
with db.cursor(conn) as cursor:
cursor.execute(
("INSERT INTO "
@@ -196,7 +220,7 @@ def register_user() -> Response:
current_app.logger.error(traceback.format_exc())
raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
- raise Exception(
+ raise Exception(# pylint: disable=[broad-exception-raised]
"unknown_error", "The system experienced an unexpected error.")
def delete_verification_code(cursor, code: str):
@@ -380,7 +404,7 @@ def send_forgot_password_email(
subject="GeneNetwork: Change Your Password"
token = secrets.token_urlsafe(64)
generated = datetime.now()
- expiration_minutes = 15
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
def __render__(template):
return render_template(template,
subject=subject,
@@ -391,7 +415,8 @@ def send_forgot_password_email(
client_id=client_id,
redirect_uri=redirect_uri,
response_type=response_type)),
- expiration_minutes=expiration_minutes)
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
with db.cursor(conn) as cursor:
cursor.execute(
diff --git a/gn_auth/settings.py b/gn_auth/settings.py
index d561fa9..d59e997 100644
--- a/gn_auth/settings.py
+++ b/gn_auth/settings.py
@@ -45,3 +45,7 @@ SMTP_TIMEOUT = 200 # seconds
SMTP_USER = "no-reply@genenetwork.org"
SMTP_PASSWORD = "asecrettoken"
EMAIL_ADDRESS = "no-reply@uthsc.edu"
+
+
+## Variable settings for various emails going out to users
+AUTH_EMAILS_EXPIRY_MINUTES = 15
diff --git a/gn_auth/smtp.py b/gn_auth/smtp.py
index 2f0e7f4..0040f35 100644
--- a/gn_auth/smtp.py
+++ b/gn_auth/smtp.py
@@ -16,7 +16,7 @@ def __read_mime__(filepath) -> dict:
return {}
-def build_email_message(# pylint: disable=[too-many-arguments]
+def build_email_message(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
from_address: str,
to_addresses: tuple[Address, ...],
subject: str,
@@ -40,7 +40,7 @@ def build_email_message(# pylint: disable=[too-many-arguments]
return msg
-def send_message(# pylint: disable=[too-many-arguments]
+def send_message(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
smtp_user: str,
smtp_passwd: str,
message: EmailMessage,
diff --git a/gn_auth/templates/base.html b/gn_auth/templates/base.html
index b452ca1..c90ac9b 100644
--- a/gn_auth/templates/base.html
+++ b/gn_auth/templates/base.html
@@ -5,7 +5,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>gn-auth: {%block title%}{%endblock%}</title>
+ <title>Authorization {%block title%}{%endblock%}</title>
<link rel="stylesheet" type="text/css"
href="https://genenetwork.org/static/new/css/bootstrap-custom.css" />
@@ -39,7 +39,7 @@
style="font-weight: bold;">GeneNetwork</a>
</li>
<li>
- <a href="#">gn-auth: {%block pagetitle%}{%endblock%}</a>
+ <a href="#">{%block pagetitle%}{%endblock%}</a>
</li>
</ul>
</div>
diff --git a/gn_auth/templates/emails/forgot-password.html b/gn_auth/templates/emails/forgot-password.html
index e40ebb8..5f16a02 100644
--- a/gn_auth/templates/emails/forgot-password.html
+++ b/gn_auth/templates/emails/forgot-password.html
@@ -24,7 +24,7 @@
</p>
<p style="font-weight: bold;color: #ee55ee;">
- The link will expire in <strong>{{expiration_minutes}}</strong> minutes.
+ The link will expire in <strong>{{expiration_minutes}}</strong>.
</p>
<hr />
diff --git a/gn_auth/templates/emails/forgot-password.txt b/gn_auth/templates/emails/forgot-password.txt
index 55a4b13..68abf16 100644
--- a/gn_auth/templates/emails/forgot-password.txt
+++ b/gn_auth/templates/emails/forgot-password.txt
@@ -7,6 +7,6 @@ You (or someone pretending to be you) made a request to change your password. Pl
If you did not request to change your password, simply ignore this email.
-The link will expire {{expiration_minutes}} minutes.
+The link will expire in {{expiration_minutes}}.
Note that if you requested to change your password multiple times, only the latest/newest token will be valid.
diff --git a/gn_auth/templates/emails/verify-email.html b/gn_auth/templates/emails/verify-email.html
index 7f85c1c..11ae575 100644
--- a/gn_auth/templates/emails/verify-email.html
+++ b/gn_auth/templates/emails/verify-email.html
@@ -20,7 +20,7 @@
<p style="font-weight: bold;color: #ee55ee;">
Please note that the verification code will expire in
- <strong>{{expiration_minutes}}</strong> minutes after it was generated.
+ <strong>{{expiration_minutes}}</strong> after it was generated.
</p>
</body>
</html>
diff --git a/gn_auth/templates/emails/verify-email.txt b/gn_auth/templates/emails/verify-email.txt
index 281d682..ecfbfc0 100644
--- a/gn_auth/templates/emails/verify-email.txt
+++ b/gn_auth/templates/emails/verify-email.txt
@@ -9,4 +9,4 @@ If that does not work, please log in to GeneNetwork and copy the verification co
{{verification_code}}
-Please note that the verification code will expire {{expiration_minutes}} minutes after it was generated.
+Please note that the verification code will expire {{expiration_minutes}} after it was generated.
diff --git a/gn_auth/templates/oauth2/authorise-user.html b/gn_auth/templates/oauth2/authorise-user.html
index 7474464..2e4e540 100644
--- a/gn_auth/templates/oauth2/authorise-user.html
+++ b/gn_auth/templates/oauth2/authorise-user.html
@@ -2,47 +2,63 @@
{%block title%}Authorise User{%endblock%}
-{%block pagetitle%}Authenticate to the API Server{%endblock%}
+{%block pagetitle%}{%endblock%}
{%block content%}
{{flash_messages()}}
-<legend style="margin-top: 20px;">User Credentials</legend>
<div class="container" style="min-width: 1250px;">
-<form method="POST" class="form-horizontal" action="{{url_for(
- 'oauth2.auth.authorise',
- response_type=response_type,
- client_id=client.client_id,
- redirect_uri=redirect_uri)}}">
- <input type="hidden" name="response_type" value="{{response_type}}" />
- <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
- <input type="hidden" name="scope" value="{{scope | join(' ')}}" />
- <input type="hidden" name="client_id" value="{{client.client_id}}" />
- <div class="form-group">
- <label for="user:email" class="form-label col-xs-1">Email</label>
- <input type="email" name="user:email" id="user:email" required="required"
- class="controls col-xs-3" size="50"/>
- </div>
+ <form method="POST"
+ class="form-horizontal"
+ action="{{url_for(
+ 'oauth2.auth.authorise',
+ response_type=response_type,
+ client_id=client.client_id,
+ redirect_uri=redirect_uri)}}"
+ style="max-width: 700px;">
+ <legend style="margin-top: 20px;">Sign In</legend>
- <div class="form-group">
- <label for="user:password" class="form-label col-xs-1">Password</label>
- <input type="password" name="user:password" id="user:password"
- required="required" class="controls col-xs-3" size="50"/>
- </div>
+ <input type="hidden" name="response_type" value="{{response_type}}" />
+ <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
+ <input type="hidden" name="scope" value="{{scope | join(' ')}}" />
+ <input type="hidden" name="client_id" value="{{client.client_id}}" />
- <div class="form-group">
- <label for="authorise" class="form-label col-xs-1"></label>
- <div class="controls col-xs-3">
- <input type="submit" value="Log in" class="btn btn-primary col-2" style="margin-left: -15px;"/>
- {%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 col-2">Forgot Password</a>
- {%endif%}
+ <div class="form-group">
+ <label for="user:email" class="control-label col-xs-2"
+ style="text-align: left;">Email</label>
+ <div class="col-xs-10">
+ <input type="email"
+ name="user:email"
+ id="user:email"
+ required="required"
+ class="form-control" />
+ </div>
</div>
- </div>
-</form>
+
+ <div class="form-group">
+ <label for="user:password" class="control-label col-xs-2"
+ style="text-align: left;">Password</label>
+ <div class="col-xs-10">
+ <input type="password"
+ name="user:password"
+ id="user:password"
+ required="required"
+ class="form-control" />
+ </div>
+ </div>
+
+ <div class="form-group">
+ <div class="controls col-xs-offset-2 col-xs-10">
+ <input type="submit" value="Sign in" class="btn btn-primary" />
+ {%if display_forgot_password%}
+ <a href="{{url_for('oauth2.users.forgot_password',
+ client_id=client.client_id,
+ redirect_uri=redirect_uri,
+ response_type=response_type)}}"
+ title="Click here to change your password."
+ class="form-text text-danger">Forgot Password</a>
+ {%endif%}
+ </div>
+ </div>
+ </form>
</div>
{%endblock%}