diff options
Diffstat (limited to 'gn_auth')
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%} |