diff options
Diffstat (limited to 'gn3')
41 files changed, 34 insertions, 916 deletions
@@ -27,7 +27,6 @@ from gn3.api.search import search from gn3.api.metadata import metadata from gn3.api.sampledata import sampledata from gn3.auth import oauth2 -from gn3.auth.authentication.oauth2.server import setup_oauth2_server def create_app(config: Union[Dict, str, None] = None) -> Flask: @@ -79,5 +78,4 @@ def create_app(config: Union[Dict, str, None] = None) -> Flask: app.register_blueprint(oauth2, url_prefix="/api/oauth2") register_error_handlers(app) - setup_oauth2_server(app) return app diff --git a/gn3/auth/__init__.py b/gn3/auth/__init__.py index a28498d..cd65e9b 100644 --- a/gn3/auth/__init__.py +++ b/gn3/auth/__init__.py @@ -1,5 +1,4 @@ """Top-Level `Auth` module""" from . import authorisation -from . import authentication from .views import oauth2 diff --git a/gn3/auth/authentication/__init__.py b/gn3/auth/authentication/__init__.py deleted file mode 100644 index 42ceacb..0000000 --- a/gn3/auth/authentication/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Handle authentication requests""" - -import bcrypt - -def credentials_in_database(cursor, email: str, password: str) -> bool: - """Check whether credentials are in the database.""" - if len(email.strip()) == 0 or len(password.strip()) == 0: - return False - - cursor.execute( - ("SELECT " - "users.email, user_credentials.password " - "FROM users LEFT JOIN user_credentials " - "ON users.user_id = user_credentials.user_id " - "WHERE users.email = :email"), - {"email": email}) - results = cursor.fetchall() - if len(results) == 0: - return False - - assert len(results) == 1, "Expected one row." - row = results[0] - return (email == row[0] and - bcrypt.checkpw(password.encode("utf-8"), row[1])) diff --git a/gn3/auth/authentication/exceptions.py b/gn3/auth/authentication/exceptions.py deleted file mode 100644 index c31e691..0000000 --- a/gn3/auth/authentication/exceptions.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Exceptions for authentication""" - -class AuthenticationError(Exception): - """Base exception class for `gn3.auth.authentication` package.""" diff --git a/gn3/auth/authentication/oauth2/__init__.py b/gn3/auth/authentication/oauth2/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/gn3/auth/authentication/oauth2/__init__.py +++ /dev/null diff --git a/gn3/auth/authentication/oauth2/endpoints/__init__.py b/gn3/auth/authentication/oauth2/endpoints/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/gn3/auth/authentication/oauth2/endpoints/__init__.py +++ /dev/null diff --git a/gn3/auth/authentication/oauth2/endpoints/introspection.py b/gn3/auth/authentication/oauth2/endpoints/introspection.py deleted file mode 100644 index cfe2998..0000000 --- a/gn3/auth/authentication/oauth2/endpoints/introspection.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Handle introspection of tokens.""" -import datetime -from urllib.parse import urlparse - -from flask import request as flask_request -from authlib.oauth2.rfc7662 import ( - IntrospectionEndpoint as _IntrospectionEndpoint) - -from gn3.auth.authentication.oauth2.models.oauth2token import OAuth2Token - -from .utilities import query_token as _query_token - -def get_token_user_sub(token: OAuth2Token) -> str:# pylint: disable=[unused-argument] - """ - Return the token's subject as defined in - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2 - """ - ## For now a dummy return to prevent issues. - return "sub" - -class IntrospectionEndpoint(_IntrospectionEndpoint): - """Introspect token.""" - def query_token(self, token_string: str, token_type_hint: str): - """Query the token.""" - return _query_token(self, token_string, token_type_hint) - - def introspect_token(self, token: OAuth2Token) -> dict: - """Return the introspection information.""" - url = urlparse(flask_request.url) - return { - "active": True, - "scope": token.get_scope(), - "client_id": token.client.client_id, - "username": token.user.name, - "token_type": token.token_type, - "exp": int(token.expires_at.timestamp()), - "iat": int(token.issued_at.timestamp()), - "nbf": int( - (token.issued_at - datetime.timedelta(seconds=120)).timestamp()), - # "sub": get_token_user_sub(token), - "aud": token.client.client_id, - "iss": f"{url.scheme}://{url.netloc}", - "jti": token.token_id - } - - def check_permission(self, token, client, request): - """Check that the client has permission to introspect token.""" - return client.client_type == "internal" diff --git a/gn3/auth/authentication/oauth2/endpoints/revocation.py b/gn3/auth/authentication/oauth2/endpoints/revocation.py deleted file mode 100644 index b8517b6..0000000 --- a/gn3/auth/authentication/oauth2/endpoints/revocation.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Handle token revocation.""" - -from flask import current_app -from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint - -from gn3.auth import db -from gn3.auth.authentication.oauth2.models.oauth2token import ( - save_token, OAuth2Token, revoke_token) - -from .utilities import query_token as _query_token - -class RevocationEndpoint(_RevocationEndpoint): - """Revoke the tokens""" - ENDPOINT_NAME = "revoke" - def query_token(self, token_string: str, token_type_hint: str): - """Query the token.""" - return _query_token(self, token_string, token_type_hint) - - def revoke_token(self, token: OAuth2Token, request): - """Revoke token `token`.""" - with db.connection(current_app.config["AUTH_DB"]) as conn: - save_token(conn, revoke_token(token)) diff --git a/gn3/auth/authentication/oauth2/endpoints/utilities.py b/gn3/auth/authentication/oauth2/endpoints/utilities.py deleted file mode 100644 index e13784e..0000000 --- a/gn3/auth/authentication/oauth2/endpoints/utilities.py +++ /dev/null @@ -1,31 +0,0 @@ -"""endpoint utilities""" -from typing import Any, Optional - -from flask import current_app -from pymonad.maybe import Nothing - -from gn3.auth import db -from gn3.auth.authentication.oauth2.models.oauth2token import ( - OAuth2Token, token_by_access_token, token_by_refresh_token) - -def query_token(# pylint: disable=[unused-argument] - endpoint_object: Any, token_str: str, token_type_hint) -> Optional[ - OAuth2Token]: - """Retrieve the token from the database.""" - def __identity__(val): - return val - token = Nothing - with db.connection(current_app.config["AUTH_DB"]) as conn: - if token_type_hint == "access_token": - token = token_by_access_token(conn, token_str) - if token_type_hint == "access_token": - token = token_by_refresh_token(conn, token_str) - - return token.maybe( - token_by_access_token(conn, token_str).maybe( - token_by_refresh_token(conn, token_str).maybe( - None, __identity__), - __identity__), - __identity__) - - return None diff --git a/gn3/auth/authentication/oauth2/grants/__init__.py b/gn3/auth/authentication/oauth2/grants/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/gn3/auth/authentication/oauth2/grants/__init__.py +++ /dev/null diff --git a/gn3/auth/authentication/oauth2/grants/authorisation_code_grant.py b/gn3/auth/authentication/oauth2/grants/authorisation_code_grant.py deleted file mode 100644 index fb8d436..0000000 --- a/gn3/auth/authentication/oauth2/grants/authorisation_code_grant.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Classes and function for Authorisation Code flow.""" -import uuid -import string -import random -from typing import Optional -from datetime import datetime - -from flask import current_app as app -from authlib.oauth2.rfc6749 import grants -from authlib.oauth2.rfc7636 import create_s256_code_challenge - -from gn3.auth import db -from gn3.auth.db_utils import with_db_connection -from gn3.auth.authentication.users import User - -from ..models.oauth2client import OAuth2Client -from ..models.authorization_code import ( - AuthorisationCode, authorisation_code, save_authorisation_code) - -class AuthorisationCodeGrant(grants.AuthorizationCodeGrant): - """Implement the 'Authorisation Code' grant.""" - TOKEN_ENDPOINT_AUTH_METHODS: list[str] = [ - "client_secret_basic", "client_secret_post"] - AUTHORIZATION_CODE_LENGTH: int = 48 - TOKEN_ENDPOINT_HTTP_METHODS = ['POST'] - GRANT_TYPE = "authorization_code" - RESPONSE_TYPES = {'code'} - - def save_authorization_code(self, code, request): - """Persist the authorisation code to database.""" - client = request.client - nonce = "".join(random.sample(string.ascii_letters + string.digits, - k=self.AUTHORIZATION_CODE_LENGTH)) - return __save_authorization_code__(AuthorisationCode( - uuid.uuid4(), code, client, request.redirect_uri, request.scope, - nonce, int(datetime.now().timestamp()), - create_s256_code_challenge(app.config["SECRET_KEY"]), - "S256", request.user)) - - def query_authorization_code(self, code, client): - """Retrieve the code from the database.""" - return __query_authorization_code__(code, client) - - def delete_authorization_code(self, authorization_code): - """Delete the authorisation code.""" - with db.connection(app.config["AUTH_DB"]) as conn: - with db.cursor(conn) as cursor: - cursor.execute( - "DELETE FROM authorisation_code WHERE code_id=?", - (str(authorization_code.code_id),)) - - def authenticate_user(self, authorization_code) -> Optional[User]: - """Authenticate the user who own the authorisation code.""" - query = ( - "SELECT users.* FROM authorisation_code LEFT JOIN users " - "ON authorisation_code.user_id=users.user_id " - "WHERE authorisation_code.code=?") - with db.connection(app.config["AUTH_DB"]) as conn: - with db.cursor(conn) as cursor: - cursor.execute(query, (str(authorization_code.code),)) - res = cursor.fetchone() - if res: - return User( - uuid.UUID(res["user_id"]), res["email"], res["name"]) - - return None - -def __query_authorization_code__( - code: str, client: OAuth2Client) -> AuthorisationCode: - """A helper function that creates a new database connection. - - This is found to be necessary since the `AuthorizationCodeGrant` class(es) - do not have a way to pass the database connection.""" - def __auth_code__(conn) -> str: - the_code = authorisation_code(conn, code, client) - return the_code.maybe(None, lambda cde: cde) # type: ignore[misc, arg-type, return-value] - - return with_db_connection(__auth_code__) - -def __save_authorization_code__(code: AuthorisationCode) -> AuthorisationCode: - """A helper function that creates a new database connection. - - This is found to be necessary since the `AuthorizationCodeGrant` class(es) - do not have a way to pass the database connection.""" - return with_db_connection(lambda conn: save_authorisation_code(conn, code)) diff --git a/gn3/auth/authentication/oauth2/grants/password_grant.py b/gn3/auth/authentication/oauth2/grants/password_grant.py deleted file mode 100644 index 3233877..0000000 --- a/gn3/auth/authentication/oauth2/grants/password_grant.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Allows users to authenticate directly.""" - -from flask import current_app as app -from authlib.oauth2.rfc6749 import grants - -from gn3.auth import db -from gn3.auth.authentication.users import valid_login, user_by_email - -from gn3.auth.authorisation.errors import NotFoundError - -class PasswordGrant(grants.ResourceOwnerPasswordCredentialsGrant): - """Implement the 'Password' grant.""" - TOKEN_ENDPOINT_AUTH_METHODS = ["client_secret_basic", "client_secret_post"] - - def authenticate_user(self, username, password): - "Authenticate the user with their username and password." - with db.connection(app.config["AUTH_DB"]) as conn: - try: - user = user_by_email(conn, username) - return user if valid_login(conn, user, password) else None - except NotFoundError as _nfe: - return None diff --git a/gn3/auth/authentication/oauth2/models/__init__.py b/gn3/auth/authentication/oauth2/models/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/gn3/auth/authentication/oauth2/models/__init__.py +++ /dev/null diff --git a/gn3/auth/authentication/oauth2/models/authorization_code.py b/gn3/auth/authentication/oauth2/models/authorization_code.py deleted file mode 100644 index f282814..0000000 --- a/gn3/auth/authentication/oauth2/models/authorization_code.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Model and functions for handling the Authorisation Code""" -from uuid import UUID -from datetime import datetime -from typing import NamedTuple - -from pymonad.maybe import Just, Maybe, Nothing - -from gn3.auth import db - -from .oauth2client import OAuth2Client - -from ...users import User, user_by_id - -__5_MINUTES__ = 300 # in seconds - -class AuthorisationCode(NamedTuple): - """ - The AuthorisationCode model for the auth(entic|oris)ation system. - """ - # Instance variables - code_id: UUID - code: str - client: OAuth2Client - redirect_uri: str - scope: str - nonce: str - auth_time: int - code_challenge: str - code_challenge_method: str - user: User - - @property - def response_type(self) -> str: - """ - For authorisation code flow, the response_type type MUST always be - 'code'. - """ - return "code" - - def is_expired(self): - """Check whether the code is expired.""" - return self.auth_time + __5_MINUTES__ < datetime.now().timestamp() - - def get_redirect_uri(self): - """Get the redirect URI""" - return self.redirect_uri - - def get_scope(self): - """Return the assigned scope for this AuthorisationCode.""" - return self.scope - - def get_nonce(self): - """Get the one-time use token.""" - return self.nonce - -def authorisation_code(conn: db.DbConnection , - code: str, - client: OAuth2Client) -> Maybe[AuthorisationCode]: - """ - Retrieve the authorisation code object that corresponds to `code` and the - given OAuth2 client. - """ - with db.cursor(conn) as cursor: - query = ("SELECT * FROM authorisation_code " - "WHERE code=:code AND client_id=:client_id") - cursor.execute( - query, {"code": code, "client_id": str(client.client_id)}) - result = cursor.fetchone() - if result: - return Just(AuthorisationCode( - UUID(result["code_id"]), result["code"], client, - result["redirect_uri"], result["scope"], result["nonce"], - int(result["auth_time"]), result["code_challenge"], - result["code_challenge_method"], - user_by_id(conn, UUID(result["user_id"])))) - return Nothing - -def save_authorisation_code(conn: db.DbConnection, - auth_code: AuthorisationCode) -> AuthorisationCode: - """Persist the `auth_code` into the database.""" - with db.cursor(conn) as cursor: - cursor.execute( - "INSERT INTO authorisation_code VALUES(" - ":code_id, :code, :client_id, :redirect_uri, :scope, :nonce, " - ":auth_time, :code_challenge, :code_challenge_method, :user_id" - ")", - { - **auth_code._asdict(), - "code_id": str(auth_code.code_id), - "client_id": str(auth_code.client.client_id), - "user_id": str(auth_code.user.user_id) - }) - return auth_code diff --git a/gn3/auth/authentication/oauth2/server.py b/gn3/auth/authentication/oauth2/server.py deleted file mode 100644 index 7d7113a..0000000 --- a/gn3/auth/authentication/oauth2/server.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Initialise the OAuth2 Server""" -import uuid -import datetime -from typing import Callable - -from flask import Flask, current_app -from authlib.oauth2.rfc6749.errors import InvalidClientError -from authlib.integrations.flask_oauth2 import AuthorizationServer -# from authlib.oauth2.rfc7636 import CodeChallenge - -from gn3.auth import db - -from .models.oauth2client import client -from .models.oauth2token import OAuth2Token, save_token - -from .grants.password_grant import PasswordGrant -from .grants.authorisation_code_grant import AuthorisationCodeGrant - -from .endpoints.revocation import RevocationEndpoint -from .endpoints.introspection import IntrospectionEndpoint - -def create_query_client_func() -> Callable: - """Create the function that loads the client.""" - def __query_client__(client_id: uuid.UUID): - # use current_app rather than passing the db_uri to avoid issues - # when config changes, e.g. while testing. - with db.connection(current_app.config["AUTH_DB"]) as conn: - the_client = client(conn, client_id).maybe( - None, lambda clt: clt) # type: ignore[misc] - if bool(the_client): - return the_client - raise InvalidClientError( - "No client found for the given CLIENT_ID and CLIENT_SECRET.") - - return __query_client__ - -def create_save_token_func(token_model: type) -> Callable: - """Create the function that saves the token.""" - def __save_token__(token, request): - with db.connection(current_app.config["AUTH_DB"]) as conn: - save_token( - conn, token_model( - token_id=uuid.uuid4(), client=request.client, - user=request.user, - **{ - "refresh_token": None, "revoked": False, - "issued_at": datetime.datetime.now(), - **token - })) - - return __save_token__ - -def setup_oauth2_server(app: Flask) -> None: - """Set's up the oauth2 server for the flask application.""" - server = AuthorizationServer() - server.register_grant(PasswordGrant) - - # Figure out a common `code_verifier` for GN2 and GN3 and set - # server.register_grant(AuthorisationCodeGrant, [CodeChallenge(required=False)]) - # below - server.register_grant(AuthorisationCodeGrant) - - # register endpoints - server.register_endpoint(RevocationEndpoint) - server.register_endpoint(IntrospectionEndpoint) - - # init server - server.init_app( - app, - query_client=create_query_client_func(), - save_token=create_save_token_func(OAuth2Token)) - app.config["OAUTH2_SERVER"] = server diff --git a/gn3/auth/authentication/oauth2/views.py b/gn3/auth/authentication/oauth2/views.py deleted file mode 100644 index 2bd3865..0000000 --- a/gn3/auth/authentication/oauth2/views.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Endpoints for the oauth2 server""" -import uuid -import traceback - -from authlib.oauth2.rfc6749.errors import InvalidClientError -from email_validator import validate_email, EmailNotValidError -from flask import ( - flash, - request, - url_for, - redirect, - Response, - Blueprint, - render_template, - current_app as app) - -from gn3.auth import db -from gn3.auth.db_utils import with_db_connection -from gn3.auth.authorisation.errors import ForbiddenAccess - -from .resource_server import require_oauth -from .endpoints.revocation import RevocationEndpoint -from .endpoints.introspection import IntrospectionEndpoint - -from ..users import valid_login, NotFoundError, user_by_email - -auth = Blueprint("auth", __name__) - -@auth.route("/delete-client/<uuid:client_id>", methods=["GET", "POST"]) -def delete_client(client_id: uuid.UUID): - """Delete an OAuth2 client.""" - return f"WOULD DELETE OAUTH2 CLIENT {client_id}." - -@auth.route("/authorise", methods=["GET", "POST"]) -def authorise(): - """Authorise a user""" - try: - server = app.config["OAUTH2_SERVER"] - client_id = uuid.UUID(request.args.get( - "client_id", - request.form.get("client_id", str(uuid.uuid4())))) - client = server.query_client(client_id) - if not bool(client): - flash("Invalid OAuth2 client.", "alert-error") - if request.method == "GET": - client = server.query_client(request.args.get("client_id")) - return render_template( - "oauth2/authorise-user.html", - client=client, - scope=client.scope, - response_type="code") - - form = request.form - def __authorise__(conn: db.DbConnection) -> Response: - email_passwd_msg = "Email or password is invalid!" - redirect_response = redirect(url_for("oauth2.auth.authorise", - client_id=client_id)) - try: - email = validate_email( - form.get("user:email"), check_deliverability=False) - user = user_by_email(conn, email["email"]) - if valid_login(conn, user, form.get("user:password", "")): - return server.create_authorization_response(request=request, grant_user=user) - flash(email_passwd_msg, "alert-error") - return redirect_response # type: ignore[return-value] - except EmailNotValidError as _enve: - app.logger.debug(traceback.format_exc()) - flash(email_passwd_msg, "alert-error") - return redirect_response # type: ignore[return-value] - except NotFoundError as _nfe: - app.logger.debug(traceback.format_exc()) - flash(email_passwd_msg, "alert-error") - return redirect_response # type: ignore[return-value] - - return with_db_connection(__authorise__) - except InvalidClientError as ice: - return render_template( - "oauth2/oauth2_error.html", error=ice), ice.status_code - -@auth.route("/token", methods=["POST"]) -def token(): - """Retrieve the authorisation token.""" - server = app.config["OAUTH2_SERVER"] - return server.create_token_response() - -@auth.route("/revoke", methods=["POST"]) -def revoke_token(): - """Revoke the token.""" - return app.config["OAUTH2_SERVER"].create_endpoint_response( - RevocationEndpoint.ENDPOINT_NAME) - -@auth.route("/introspect", methods=["POST"]) -@require_oauth("introspect") -def introspect_token() -> Response: - """Provide introspection information for the token.""" - # This is dangerous to provide publicly - authorised_clients = app.config.get( - "OAUTH2_CLIENTS_WITH_INTROSPECTION_PRIVILEGE", []) - with require_oauth.acquire("introspect") as the_token: - if the_token.client.client_id in authorised_clients: - return app.config["OAUTH2_SERVER"].create_endpoint_response( - IntrospectionEndpoint.ENDPOINT_NAME) - - raise ForbiddenAccess("You cannot access this endpoint") diff --git a/gn3/auth/authorisation/checks.py b/gn3/auth/authorisation/checks.py index 1c87c02..17daca4 100644 --- a/gn3/auth/authorisation/checks.py +++ b/gn3/auth/authorisation/checks.py @@ -5,12 +5,11 @@ from typing import Callable from flask import request, current_app as app from gn3.auth import db +from gn3.auth.authorisation.oauth2.resource_server import require_oauth from . import privileges as auth_privs from .errors import InvalidData, AuthorisationError -from ..authentication.oauth2.resource_server import require_oauth - def __system_privileges_in_roles__(conn, user): """ This really is a hack since groups are not treated as resources at the diff --git a/gn3/auth/authorisation/data/views.py b/gn3/auth/authorisation/data/views.py index 8adf862..81811dd 100644 --- a/gn3/auth/authorisation/data/views.py +++ b/gn3/auth/authorisation/data/views.py @@ -29,9 +29,9 @@ from gn3.auth.authorisation.resources.checks import authorised_for from gn3.auth.authorisation.resources.models import ( user_resources, public_resources, attach_resources_data) -from gn3.auth.authentication.users import User -from gn3.auth.authentication.oauth2.resource_server import require_oauth +from gn3.auth.authorisation.oauth2.resource_server import require_oauth +from gn3.auth.authorisation.users import User from gn3.auth.authorisation.data.phenotypes import link_phenotype_data from gn3.auth.authorisation.data.mrna import link_mrna_data, ungrouped_mrna_data from gn3.auth.authorisation.data.genotypes import ( diff --git a/gn3/auth/authorisation/groups/models.py b/gn3/auth/authorisation/groups/models.py index 5a3ae50..7212a78 100644 --- a/gn3/auth/authorisation/groups/models.py +++ b/gn3/auth/authorisation/groups/models.py @@ -9,7 +9,7 @@ from pymonad.maybe import Just, Maybe, Nothing from gn3.auth import db from gn3.auth.dictify import dictify -from gn3.auth.authentication.users import User, user_by_id +from gn3.auth.authorisation.users import User, user_by_id from ..checks import authorised_p from ..privileges import Privilege diff --git a/gn3/auth/authorisation/groups/views.py b/gn3/auth/authorisation/groups/views.py index 628df36..a849a73 100644 --- a/gn3/auth/authorisation/groups/views.py +++ b/gn3/auth/authorisation/groups/views.py @@ -12,6 +12,8 @@ from gn3 import db_utils as gn3db from gn3.auth.dictify import dictify from gn3.auth.db_utils import with_db_connection +from gn3.auth.authorisation.users import User +from gn3.auth.authorisation.oauth2.resource_server import require_oauth from .data import link_data_to_group from .models import ( @@ -28,9 +30,6 @@ from ..checks import authorised_p from ..privileges import Privilege, privileges_by_ids from ..errors import InvalidData, NotFoundError, AuthorisationError -from ...authentication.users import User -from ...authentication.oauth2.resource_server import require_oauth - groups = Blueprint("groups", __name__) @groups.route("/list", methods=["GET"]) diff --git a/gn3/auth/authentication/oauth2/models/oauth2client.py b/gn3/auth/authorisation/oauth2/oauth2client.py index 2a307e3..dc54a41 100644 --- a/gn3/auth/authentication/oauth2/models/oauth2client.py +++ b/gn3/auth/authorisation/oauth2/oauth2client.py @@ -7,9 +7,9 @@ from typing import Sequence, Optional, NamedTuple from pymonad.maybe import Just, Maybe, Nothing from gn3.auth import db -from gn3.auth.authentication.users import User, users, user_by_id, same_password from gn3.auth.authorisation.errors import NotFoundError +from gn3.auth.authorisation.users import User, users, user_by_id, same_password class OAuth2Client(NamedTuple): """ diff --git a/gn3/auth/authentication/oauth2/models/oauth2token.py b/gn3/auth/authorisation/oauth2/oauth2token.py index bfe4aaf..bb19039 100644 --- a/gn3/auth/authentication/oauth2/models/oauth2token.py +++ b/gn3/auth/authorisation/oauth2/oauth2token.py @@ -6,9 +6,9 @@ from typing import NamedTuple, Optional from pymonad.maybe import Just, Maybe, Nothing from gn3.auth import db -from gn3.auth.authentication.users import User, user_by_id from gn3.auth.authorisation.errors import NotFoundError +from gn3.auth.authorisation.users import User, user_by_id from .oauth2client import client, OAuth2Client diff --git a/gn3/auth/authentication/oauth2/resource_server.py b/gn3/auth/authorisation/oauth2/resource_server.py index 223e811..e806dc5 100644 --- a/gn3/auth/authentication/oauth2/resource_server.py +++ b/gn3/auth/authorisation/oauth2/resource_server.py @@ -5,7 +5,7 @@ from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator from authlib.integrations.flask_oauth2 import ResourceProtector from gn3.auth import db -from gn3.auth.authentication.oauth2.models.oauth2token import token_by_access_token +from gn3.auth.authorisation.oauth2.oauth2token import token_by_access_token class BearerTokenValidator(_BearerTokenValidator): """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`""" diff --git a/gn3/auth/authorisation/privileges.py b/gn3/auth/authorisation/privileges.py index dbb4129..7907d76 100644 --- a/gn3/auth/authorisation/privileges.py +++ b/gn3/auth/authorisation/privileges.py @@ -2,7 +2,7 @@ from typing import Any, Iterable, NamedTuple from gn3.auth import db -from gn3.auth.authentication.users import User +from gn3.auth.authorisation.users import User class Privilege(NamedTuple): """Class representing a privilege: creates immutable objects.""" diff --git a/gn3/auth/authorisation/resources/checks.py b/gn3/auth/authorisation/resources/checks.py index fafde76..1f5a0f9 100644 --- a/gn3/auth/authorisation/resources/checks.py +++ b/gn3/auth/authorisation/resources/checks.py @@ -4,7 +4,7 @@ from functools import reduce from typing import Sequence from gn3.auth import db -from gn3.auth.authentication.users import User +from gn3.auth.authorisation.users import User def __organise_privileges_by_resource_id__(rows): def __organise__(privs, row): diff --git a/gn3/auth/authorisation/resources/models.py b/gn3/auth/authorisation/resources/models.py index b301a93..cf7769e 100644 --- a/gn3/auth/authorisation/resources/models.py +++ b/gn3/auth/authorisation/resources/models.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Sequence, Optional, NamedTuple from gn3.auth import db from gn3.auth.dictify import dictify -from gn3.auth.authentication.users import User +from gn3.auth.authorisation.users import User from gn3.auth.db_utils import with_db_connection from .checks import authorised_for diff --git a/gn3/auth/authorisation/resources/views.py b/gn3/auth/authorisation/resources/views.py index 3b2bbeb..bda67cd 100644 --- a/gn3/auth/authorisation/resources/views.py +++ b/gn3/auth/authorisation/resources/views.py @@ -7,6 +7,8 @@ from functools import reduce from flask import request, jsonify, Response, Blueprint, current_app as app from gn3.auth.db_utils import with_db_connection +from gn3.auth.authorisation.oauth2.resource_server import require_oauth +from gn3.auth.authorisation.users import User, user_by_id, user_by_email from .checks import authorised_for from .models import ( @@ -21,8 +23,6 @@ from ..groups.models import Group, GroupRole, group_role_by_id from ... import db from ...dictify import dictify -from ...authentication.oauth2.resource_server import require_oauth -from ...authentication.users import User, user_by_id, user_by_email resources = Blueprint("resources", __name__) diff --git a/gn3/auth/authorisation/roles/models.py b/gn3/auth/authorisation/roles/models.py index 97e11af..890d33b 100644 --- a/gn3/auth/authorisation/roles/models.py +++ b/gn3/auth/authorisation/roles/models.py @@ -7,7 +7,7 @@ from pymonad.either import Left, Right, Either from gn3.auth import db from gn3.auth.dictify import dictify -from gn3.auth.authentication.users import User +from gn3.auth.authorisation.users import User from gn3.auth.authorisation.errors import AuthorisationError from ..checks import authorised_p diff --git a/gn3/auth/authorisation/roles/views.py b/gn3/auth/authorisation/roles/views.py index 3670aab..d00e596 100644 --- a/gn3/auth/authorisation/roles/views.py +++ b/gn3/auth/authorisation/roles/views.py @@ -5,11 +5,10 @@ from flask import jsonify, Response, Blueprint, current_app from gn3.auth import db from gn3.auth.dictify import dictify +from gn3.auth.authorisation.oauth2.resource_server import require_oauth from .models import user_role -from ...authentication.oauth2.resource_server import require_oauth - roles = Blueprint("roles", __name__) @roles.route("/view/<uuid:role_id>", methods=["GET"]) diff --git a/gn3/auth/authorisation/users/__init__.py b/gn3/auth/authorisation/users/__init__.py index e69de29..5f0c89c 100644 --- a/gn3/auth/authorisation/users/__init__.py +++ b/gn3/auth/authorisation/users/__init__.py @@ -0,0 +1,12 @@ +"""Initialise the users' package.""" +from .base import ( + User, + users, + save_user, + user_by_id, + # valid_login, + user_by_email, + hash_password, # only used in tests... maybe make gn-auth a GN3 dependency + same_password, + set_user_password +) diff --git a/gn3/auth/authorisation/users/admin/__init__.py b/gn3/auth/authorisation/users/admin/__init__.py deleted file mode 100644 index 8aa0743..0000000 --- a/gn3/auth/authorisation/users/admin/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""The admin module""" -from .views import admin diff --git a/gn3/auth/authorisation/users/admin/ui.py b/gn3/auth/authorisation/users/admin/ui.py deleted file mode 100644 index 242c7a6..0000000 --- a/gn3/auth/authorisation/users/admin/ui.py +++ /dev/null @@ -1,27 +0,0 @@ -"""UI utilities for the auth system.""" -from functools import wraps -from flask import flash, url_for, redirect - -from gn3.auth.authentication.users import User -from gn3.auth.db_utils import with_db_connection -from gn3.auth.authorisation.roles.models import user_roles - -from gn3.session import logged_in, session_user, clear_session_info - -def is_admin(func): - """Verify user is a system admin.""" - @wraps(func) - @logged_in - def __admin__(*args, **kwargs): - admin_roles = [ - role for role in with_db_connection( - lambda conn: user_roles( - conn, User(**session_user()))) - if role.role_name == "system-administrator"] - if len(admin_roles) > 0: - return func(*args, **kwargs) - flash("Expected a system administrator.", "alert-danger") - flash("You have been logged out of the system.", "alert-info") - clear_session_info() - return redirect(url_for("oauth2.admin.login")) - return __admin__ diff --git a/gn3/auth/authorisation/users/admin/views.py b/gn3/auth/authorisation/users/admin/views.py deleted file mode 100644 index c9f1887..0000000 --- a/gn3/auth/authorisation/users/admin/views.py +++ /dev/null @@ -1,230 +0,0 @@ -"""UI for admin stuff""" -import uuid -import json -import random -import string -from functools import partial -from datetime import datetime, timezone, timedelta - -from email_validator import validate_email, EmailNotValidError -from flask import ( - flash, - request, - url_for, - redirect, - Blueprint, - current_app, - render_template) - - -from gn3 import session -from gn3.auth import db -from gn3.auth.db_utils import with_db_connection - -from gn3.auth.authentication.oauth2.models.oauth2client import ( - save_client, - OAuth2Client, - oauth2_clients, - client as oauth2_client, - delete_client as _delete_client) -from gn3.auth.authentication.users import ( - User, - user_by_id, - valid_login, - user_by_email, - hash_password) - -from .ui import is_admin - -admin = Blueprint("admin", __name__) - -@admin.before_request -def update_expires(): - """Update session expiration.""" - if session.session_info() and not session.update_expiry(): - flash("Session has expired. Logging out...", "alert-warning") - session.clear_session_info() - return redirect(url_for("oauth2.admin.login")) - return None - -@admin.route("/dashboard", methods=["GET"]) -@is_admin -def dashboard(): - """Admin dashboard.""" - return render_template("admin/dashboard.html") - -@admin.route("/login", methods=["GET", "POST"]) -def login(): - """Log in to GN3 directly without OAuth2 client.""" - if request.method == "GET": - return render_template( - "admin/login.html", - next_uri=request.args.get("next", "oauth2.admin.dashboard")) - - form = request.form - next_uri = form.get("next_uri", "oauth2.admin.dashboard") - error_message = "Invalid email or password provided." - login_page = redirect(url_for("oauth2.admin.login", next=next_uri)) - try: - email = validate_email(form.get("email", "").strip(), - check_deliverability=False) - password = form.get("password") - with db.connection(current_app.config["AUTH_DB"]) as conn: - user = user_by_email(conn, email["email"]) - if valid_login(conn, user, password): - session.update_session_info( - user=user._asdict(), - expires=( - datetime.now(tz=timezone.utc) + timedelta(minutes=10))) - return redirect(url_for(next_uri)) - flash(error_message, "alert-danger") - return login_page - except EmailNotValidError as _enve: - flash(error_message, "alert-danger") - return login_page - -@admin.route("/logout", methods=["GET"]) -def logout(): - """Log out the admin.""" - if not session.session_info(): - flash("Not logged in.", "alert-info") - return redirect(url_for("oauth2.admin.login")) - session.clear_session_info() - flash("Logged out", "alert-success") - return redirect(url_for("oauth2.admin.login")) - -def random_string(length: int = 64) -> str: - """Generate a random string.""" - return "".join( - random.choice(string.ascii_letters + string.digits + string.punctuation) - for _idx in range(0, length)) - -def __response_types__(grant_types: tuple[str, ...]) -> tuple[str, ...]: - """Compute response types from grant types.""" - resps = { - "password": ("token",), - "authorization_code": ("token", "code"), - "refresh_token": ("token",) - } - return tuple(set( - resp_typ for types_list - in (types for grant, types in resps.items() if grant in grant_types) - for resp_typ in types_list)) - -@admin.route("/register-client", methods=["GET", "POST"]) -@is_admin -def register_client(): - """Register an OAuth2 client.""" - def __list_users__(conn): - with db.cursor(conn) as cursor: - cursor.execute("SELECT * FROM users") - return tuple( - User(uuid.UUID(row["user_id"]), row["email"], row["name"]) - for row in cursor.fetchall()) - if request.method == "GET": - return render_template( - "admin/register-client.html", - scope=current_app.config["OAUTH2_SCOPE"], - users=with_db_connection(__list_users__), - current_user=session.session_user()) - - form = request.form - raw_client_secret = random_string() - default_redirect_uri = form["redirect_uri"] - grant_types = form.getlist("grants[]") - client = OAuth2Client( - client_id = uuid.uuid4(), - client_secret = hash_password(raw_client_secret), - client_id_issued_at = datetime.now(tz=timezone.utc), - client_secret_expires_at = datetime.fromtimestamp(0), - client_metadata = { - "client_name": "GN2 Dev Server", - "token_endpoint_auth_method": [ - "client_secret_post", "client_secret_basic"], - "client_type": "confidential", - "grant_types": ["password", "authorization_code", "refresh_token"], - "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[]") - }, - user = with_db_connection(partial( - user_by_id, user_id=uuid.UUID(form["user"]))) - ) - client = with_db_connection(partial(save_client, the_client=client)) - return render_template( - "admin/registered-client.html", - client=client, - client_secret = raw_client_secret) - -def __parse_client__(sqlite3_row) -> dict: - """Parse the client details into python datatypes.""" - return { - **dict(sqlite3_row), - "client_metadata": json.loads(sqlite3_row["client_metadata"]) - } - -@admin.route("/list-client", methods=["GET"]) -@is_admin -def list_clients(): - """List all registered OAuth2 clients.""" - return render_template( - "admin/list-oauth2-clients.html", - clients=with_db_connection(oauth2_clients)) - -@admin.route("/view-client/<uuid:client_id>", methods=["GET"]) -@is_admin -def view_client(client_id: uuid.UUID): - """View details of OAuth2 client with given `client_id`.""" - return render_template( - "admin/view-oauth2-client.html", - client=with_db_connection(partial(oauth2_client, client_id=client_id)), - scope=current_app.config["OAUTH2_SCOPE"]) - -@admin.route("/edit-client", methods=["POST"]) -@is_admin -def edit_client(): - """Edit the details of the given client.""" - form = request.form - the_client = with_db_connection(partial( - oauth2_client, client_id=uuid.UUID(form["client_id"]))) - if the_client.is_nothing(): - flash("No such client.", "alert-danger") - return redirect(url_for("oauth2.admin.list_clients")) - the_client = the_client.value - client_metadata = { - **the_client.client_metadata, - "default_redirect_uri": form["default_redirect_uri"], - "redirect_uris": list(set( - [form["default_redirect_uri"]] + - form["other_redirect_uris"].split("\r\n"))), - "grants": form.getlist("grants[]"), - "scope": form.getlist("scope[]") - } - with_db_connection(partial(save_client, the_client=OAuth2Client( - the_client.client_id, - the_client.client_secret, - the_client.client_id_issued_at, - the_client.client_secret_expires_at, - client_metadata, - the_client.user))) - flash("Client updated.", "alert-success") - return redirect(url_for("oauth2.admin.view_client", - client_id=the_client.client_id)) - -@admin.route("/delete-client", methods=["POST"]) -@is_admin -def delete_client(): - """Delete the details of the client.""" - form = request.form - the_client = with_db_connection(partial( - oauth2_client, client_id=uuid.UUID(form["client_id"]))) - if the_client.is_nothing(): - flash("No such client.", "alert-danger") - return redirect(url_for("oauth2.admin.list_clients")) - the_client = the_client.value - with_db_connection(partial(_delete_client, client=the_client)) - flash((f"Client '{the_client.client_metadata.client_name}' was deleted " - "successfully."), - "alert-success") - return redirect(url_for("oauth2.admin.list_clients")) diff --git a/gn3/auth/authentication/users.py b/gn3/auth/authorisation/users/base.py index 0e72ed2..0e72ed2 100644 --- a/gn3/auth/authentication/users.py +++ b/gn3/auth/authorisation/users/base.py diff --git a/gn3/auth/authorisation/users/collections/views.py b/gn3/auth/authorisation/users/collections/views.py index 1fa25a3..775e8bc 100644 --- a/gn3/auth/authorisation/users/collections/views.py +++ b/gn3/auth/authorisation/users/collections/views.py @@ -9,8 +9,8 @@ from gn3.auth.db_utils import with_db_connection from gn3.auth.authorisation.checks import require_json from gn3.auth.authorisation.errors import NotFoundError -from gn3.auth.authentication.users import User, user_by_id -from gn3.auth.authentication.oauth2.resource_server import require_oauth +from gn3.auth.authorisation.users import User, user_by_id +from gn3.auth.authorisation.oauth2.resource_server import require_oauth from .models import ( add_traits, diff --git a/gn3/auth/authorisation/users/masquerade/__init__.py b/gn3/auth/authorisation/users/masquerade/__init__.py deleted file mode 100644 index 69d64f0..0000000 --- a/gn3/auth/authorisation/users/masquerade/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package to deal with masquerading.""" diff --git a/gn3/auth/authorisation/users/masquerade/models.py b/gn3/auth/authorisation/users/masquerade/models.py deleted file mode 100644 index 9f24b6b..0000000 --- a/gn3/auth/authorisation/users/masquerade/models.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Functions for handling masquerade.""" -from uuid import uuid4 -from functools import wraps -from datetime import datetime - -from flask import current_app as app - -from gn3.auth import db - -from gn3.auth.authorisation.errors import ForbiddenAccess -from gn3.auth.authorisation.roles.models import user_roles - -from gn3.auth.authentication.users import User -from gn3.auth.authentication.oauth2.models.oauth2token import ( - OAuth2Token, save_token) - -__FIVE_HOURS__ = (60 * 60 * 5) - -def can_masquerade(func): - """Security decorator.""" - @wraps(func) - def __checker__(*args, **kwargs): - if len(args) == 3: - conn, token, _masq_user = args - elif len(args) == 2: - conn, token = args - elif len(args) == 1: - conn = args[0] - token = kwargs["original_token"] - else: - 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"] - if len(masq_privs) == 0: - raise ForbiddenAccess( - "You do not have the ability to masquerade as another user.") - return func(*args, **kwargs) - return __checker__ - -@can_masquerade -def masquerade_as( - conn: db.DbConnection, - original_token: OAuth2Token, - masqueradee: User) -> OAuth2Token: - """Get a token that enables `masquerader` to act as `masqueradee`.""" - token_details = app.config["OAUTH2_SERVER"].generate_token( - client=original_token.client, - grant_type="authorization_code", - user=masqueradee, - expires_in=__FIVE_HOURS__, - include_refresh_token=True) - new_token = OAuth2Token( - token_id=uuid4(), - client=original_token.client, - token_type=token_details["token_type"], - access_token=token_details["access_token"], - refresh_token=token_details.get("refresh_token"), - scope=original_token.scope, - revoked=False, - issued_at=datetime.now(), - expires_in=token_details["expires_in"], - user=masqueradee) - save_token(conn, new_token) - return new_token diff --git a/gn3/auth/authorisation/users/masquerade/views.py b/gn3/auth/authorisation/users/masquerade/views.py deleted file mode 100644 index 43286a1..0000000 --- a/gn3/auth/authorisation/users/masquerade/views.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Endpoints for user masquerade""" -from uuid import UUID -from functools import partial - -from flask import request, jsonify, Response, Blueprint - -from gn3.auth.db_utils import with_db_connection -from gn3.auth.authorisation.errors import InvalidData -from gn3.auth.authorisation.checks import require_json - -from gn3.auth.authentication.users import user_by_id -from gn3.auth.authentication.oauth2.resource_server import require_oauth - -from .models import masquerade_as - -masq = Blueprint("masquerade", __name__) - -@masq.route("/", methods=["POST"]) -@require_oauth("profile user masquerade") -@require_json -def masquerade() -> Response: - """Masquerade as a particular user.""" - with require_oauth.acquire("profile user masquerade") as token: - masqueradee_id = UUID(request.json["masquerade_as"])#type: ignore[index] - if masqueradee_id == token.user.user_id: - raise InvalidData("You are not allowed to masquerade as yourself.") - - masq_user = with_db_connection(partial( - user_by_id, user_id=masqueradee_id)) - def __masq__(conn): - new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user) - return new_token - def __dump_token__(tok): - return { - key: value for key, value in (tok._asdict().items()) - if key in ("access_token", "refresh_token", "expires_in", - "token_type") - } - return jsonify({ - "original": { - "user": token.user._asdict(), - "token": __dump_token__(token) - }, - "masquerade_as": { - "user": masq_user._asdict(), - "token": __dump_token__(with_db_connection(__masq__)) - } - }) diff --git a/gn3/auth/authorisation/users/models.py b/gn3/auth/authorisation/users/models.py index 89c1d22..0157154 100644 --- a/gn3/auth/authorisation/users/models.py +++ b/gn3/auth/authorisation/users/models.py @@ -7,7 +7,7 @@ from gn3.auth.authorisation.roles.models import Role from gn3.auth.authorisation.checks import authorised_p from gn3.auth.authorisation.privileges import Privilege -from gn3.auth.authentication.users import User +from .base import User @authorised_p( ("system:user:list",), diff --git a/gn3/auth/authorisation/users/views.py b/gn3/auth/authorisation/users/views.py index 826e222..f75b51e 100644 --- a/gn3/auth/authorisation/users/views.py +++ b/gn3/auth/authorisation/users/views.py @@ -10,9 +10,11 @@ from flask import request, jsonify, Response, Blueprint, current_app from gn3.auth import db from gn3.auth.dictify import dictify from gn3.auth.db_utils import with_db_connection +from gn3.auth.authorisation.oauth2.resource_server import require_oauth +from gn3.auth.authorisation.users import User, save_user, set_user_password +from gn3.auth.authorisation.oauth2.oauth2token import token_by_access_token from .models import list_users -from .masquerade.views import masq from .collections.views import collections from ..groups.models import user_group as _user_group @@ -21,12 +23,7 @@ from ..roles.models import assign_default_roles, user_roles as _user_roles from ..errors import ( NotFoundError, UsernameError, PasswordError, UserRegistrationError) -from ...authentication.oauth2.resource_server import require_oauth -from ...authentication.users import User, save_user, set_user_password -from ...authentication.oauth2.models.oauth2token import token_by_access_token - users = Blueprint("users", __name__) -users.register_blueprint(masq, url_prefix="/masquerade") users.register_blueprint(collections, url_prefix="/collections") @users.route("/", methods=["GET"]) diff --git a/gn3/auth/views.py b/gn3/auth/views.py index 56eace7..da64049 100644 --- a/gn3/auth/views.py +++ b/gn3/auth/views.py @@ -1,21 +1,16 @@ """The Auth(oris|entic)ation routes/views""" from flask import Blueprint -from .authentication.oauth2.views import auth - from .authorisation.data.views import data from .authorisation.users.views import users -from .authorisation.users.admin import admin from .authorisation.roles.views import roles from .authorisation.groups.views import groups from .authorisation.resources.views import resources oauth2 = Blueprint("oauth2", __name__) -oauth2.register_blueprint(auth, url_prefix="/") oauth2.register_blueprint(data, url_prefix="/data") oauth2.register_blueprint(users, url_prefix="/user") oauth2.register_blueprint(roles, url_prefix="/role") -oauth2.register_blueprint(admin, url_prefix="/admin") oauth2.register_blueprint(groups, url_prefix="/group") oauth2.register_blueprint(resources, url_prefix="/resource") |