From 0a8279891190e49867d3a1d72db0f7c7cd275646 Mon Sep 17 00:00:00 2001 From: Frederick Muriuki Muriithi Date: Thu, 7 Sep 2023 15:49:00 +0300 Subject: Remove authentication from GN3 Authentication should be handled by the auth server (gn-auth) and thus, this commit removes code handling user authentication from the GN3 system. --- gn3/auth/authorisation/checks.py | 3 +- gn3/auth/authorisation/data/views.py | 4 +- gn3/auth/authorisation/groups/models.py | 2 +- gn3/auth/authorisation/groups/views.py | 5 +- gn3/auth/authorisation/oauth2/oauth2client.py | 234 +++++++++++++++++++++ gn3/auth/authorisation/oauth2/oauth2token.py | 133 ++++++++++++ gn3/auth/authorisation/oauth2/resource_server.py | 19 ++ gn3/auth/authorisation/privileges.py | 2 +- gn3/auth/authorisation/resources/checks.py | 2 +- gn3/auth/authorisation/resources/models.py | 2 +- gn3/auth/authorisation/resources/views.py | 4 +- gn3/auth/authorisation/roles/models.py | 2 +- gn3/auth/authorisation/roles/views.py | 3 +- gn3/auth/authorisation/users/__init__.py | 12 ++ gn3/auth/authorisation/users/admin/__init__.py | 2 - gn3/auth/authorisation/users/admin/ui.py | 27 --- gn3/auth/authorisation/users/admin/views.py | 230 -------------------- gn3/auth/authorisation/users/base.py | 128 +++++++++++ gn3/auth/authorisation/users/collections/views.py | 4 +- .../authorisation/users/masquerade/__init__.py | 1 - gn3/auth/authorisation/users/masquerade/models.py | 67 ------ gn3/auth/authorisation/users/masquerade/views.py | 48 ----- gn3/auth/authorisation/users/models.py | 2 +- gn3/auth/authorisation/users/views.py | 9 +- 24 files changed, 545 insertions(+), 400 deletions(-) create mode 100644 gn3/auth/authorisation/oauth2/oauth2client.py create mode 100644 gn3/auth/authorisation/oauth2/oauth2token.py create mode 100644 gn3/auth/authorisation/oauth2/resource_server.py delete mode 100644 gn3/auth/authorisation/users/admin/__init__.py delete mode 100644 gn3/auth/authorisation/users/admin/ui.py delete mode 100644 gn3/auth/authorisation/users/admin/views.py create mode 100644 gn3/auth/authorisation/users/base.py delete mode 100644 gn3/auth/authorisation/users/masquerade/__init__.py delete mode 100644 gn3/auth/authorisation/users/masquerade/models.py delete mode 100644 gn3/auth/authorisation/users/masquerade/views.py (limited to 'gn3/auth/authorisation') 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/authorisation/oauth2/oauth2client.py b/gn3/auth/authorisation/oauth2/oauth2client.py new file mode 100644 index 0000000..dc54a41 --- /dev/null +++ b/gn3/auth/authorisation/oauth2/oauth2client.py @@ -0,0 +1,234 @@ +"""OAuth2 Client model.""" +import json +import datetime +from uuid import UUID +from typing import Sequence, Optional, NamedTuple + +from pymonad.maybe import Just, Maybe, Nothing + +from gn3.auth import db + +from gn3.auth.authorisation.errors import NotFoundError +from gn3.auth.authorisation.users import User, users, user_by_id, same_password + +class OAuth2Client(NamedTuple): + """ + Client to the OAuth2 Server. + + This is defined according to the mixin at + https://docs.authlib.org/en/latest/specs/rfc6749.html#authlib.oauth2.rfc6749.ClientMixin + """ + client_id: UUID + client_secret: str + client_id_issued_at: datetime.datetime + client_secret_expires_at: datetime.datetime + client_metadata: dict + user: User + + def check_client_secret(self, client_secret: str) -> bool: + """Check whether the `client_secret` matches this client.""" + return same_password(client_secret, self.client_secret) + + @property + def token_endpoint_auth_method(self) -> str: + """Return the token endpoint authorisation method.""" + return self.client_metadata.get("token_endpoint_auth_method", ["none"]) + + @property + def client_type(self) -> str: + """ + Return the token endpoint authorisation method. + + Acceptable client types: + * public: Unable to use registered client secrets, e.g. browsers, apps + on mobile devices. + * confidential: able to securely authenticate with authorisation server + e.g. being able to keep their registered client secret safe. + """ + return self.client_metadata.get("client_type", "public") + + def check_endpoint_auth_method(self, method: str, endpoint: str) -> bool: + """ + Check if the client supports the given method for the given endpoint. + + Acceptable methods: + * none: Client is a public client and does not have a client secret + * client_secret_post: Client uses the HTTP POST parameters + * client_secret_basic: Client uses HTTP Basic + """ + if endpoint == "token": + return (method in self.token_endpoint_auth_method + and method == "client_secret_post") + if endpoint in ("introspection", "revoke"): + return (method in self.token_endpoint_auth_method + and method == "client_secret_basic") + return False + + @property + def id(self):# pylint: disable=[invalid-name] + """Return the client_id.""" + return self.client_id + + @property + def grant_types(self) -> Sequence[str]: + """ + Return the grant types that this client supports. + + Valid grant types: + * authorisation_code + * implicit + * client_credentials + * password + """ + return self.client_metadata.get("grant_types", []) + + def check_grant_type(self, grant_type: str) -> bool: + """ + Validate that client can handle the given grant types + """ + return grant_type in self.grant_types + + @property + def redirect_uris(self) -> Sequence[str]: + """Return the redirect_uris that this client supports.""" + return self.client_metadata.get('redirect_uris', []) + + def check_redirect_uri(self, redirect_uri: str) -> bool: + """ + Check whether the given `redirect_uri` is one of the expected ones. + """ + return redirect_uri in self.redirect_uris + + @property + def response_types(self) -> Sequence[str]: + """Return the response_types that this client supports.""" + return self.client_metadata.get("response_type", []) + + def check_response_type(self, response_type: str) -> bool: + """Check whether this client supports `response_type`.""" + return response_type in self.response_types + + @property + def scope(self) -> Sequence[str]: + """Return valid scopes for this client.""" + return tuple(set(self.client_metadata.get("scope", []))) + + def get_allowed_scope(self, scope: str) -> str: + """Return list of scopes in `scope` that are supported by this client.""" + if not bool(scope): + return "" + requested = scope.split() + return " ".join(sorted(set( + scp for scp in requested if scp in self.scope))) + + def get_client_id(self): + """Return this client's identifier.""" + return self.client_id + + def get_default_redirect_uri(self) -> str: + """Return the default redirect uri""" + return self.client_metadata.get("default_redirect_uri", "") + +def client(conn: db.DbConnection, client_id: UUID, + user: Optional[User] = None) -> Maybe: + """Retrieve a client by its ID""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT * FROM oauth2_clients WHERE client_id=?", (str(client_id),)) + result = cursor.fetchone() + the_user = user + if result: + if not bool(the_user): + try: + the_user = user_by_id(conn, result["user_id"]) + except NotFoundError as _nfe: + the_user = None + + return Just( + OAuth2Client(UUID(result["client_id"]), + result["client_secret"], + datetime.datetime.fromtimestamp( + result["client_id_issued_at"]), + datetime.datetime.fromtimestamp( + result["client_secret_expires_at"]), + json.loads(result["client_metadata"]), + the_user))# type: ignore[arg-type] + + return Nothing + +def client_by_id_and_secret(conn: db.DbConnection, client_id: UUID, + client_secret: str) -> OAuth2Client: + """Retrieve a client by its ID and secret""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT * FROM oauth2_clients WHERE client_id=?", + (str(client_id),)) + row = cursor.fetchone() + if bool(row) and same_password(client_secret, row["client_secret"]): + return OAuth2Client( + client_id, client_secret, + datetime.datetime.fromtimestamp(row["client_id_issued_at"]), + datetime.datetime.fromtimestamp( + row["client_secret_expires_at"]), + json.loads(row["client_metadata"]), + user_by_id(conn, UUID(row["user_id"]))) + + raise NotFoundError("Could not find client with the given credentials.") + +def save_client(conn: db.DbConnection, the_client: OAuth2Client) -> OAuth2Client: + """Persist the client details into the database.""" + with db.cursor(conn) as cursor: + query = ( + "INSERT INTO oauth2_clients " + "(client_id, client_secret, client_id_issued_at, " + "client_secret_expires_at, client_metadata, user_id) " + "VALUES " + "(:client_id, :client_secret, :client_id_issued_at, " + ":client_secret_expires_at, :client_metadata, :user_id) " + "ON CONFLICT (client_id) DO UPDATE SET " + "client_secret=:client_secret, " + "client_id_issued_at=:client_id_issued_at, " + "client_secret_expires_at=:client_secret_expires_at, " + "client_metadata=:client_metadata, user_id=:user_id") + cursor.execute( + query, + { + "client_id": str(the_client.client_id), + "client_secret": the_client.client_secret, + "client_id_issued_at": ( + the_client.client_id_issued_at.timestamp()), + "client_secret_expires_at": ( + the_client.client_secret_expires_at.timestamp()), + "client_metadata": json.dumps(the_client.client_metadata), + "user_id": str(the_client.user.user_id) + }) + return the_client + +def oauth2_clients(conn: db.DbConnection) -> tuple[OAuth2Client, ...]: + """Fetch a list of all OAuth2 clients.""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM oauth2_clients") + clients_rs = cursor.fetchall() + the_users = { + usr.user_id: usr for usr in users( + conn, tuple({UUID(result["user_id"]) for result in clients_rs})) + } + return tuple(OAuth2Client(UUID(result["client_id"]), + result["client_secret"], + datetime.datetime.fromtimestamp( + result["client_id_issued_at"]), + datetime.datetime.fromtimestamp( + result["client_secret_expires_at"]), + json.loads(result["client_metadata"]), + the_users[UUID(result["user_id"])]) + for result in clients_rs) + +def delete_client(conn: db.DbConnection, the_client: OAuth2Client) -> OAuth2Client: + """Delete the given client from the database""" + with db.cursor(conn) as cursor: + params = (str(the_client.client_id),) + cursor.execute("DELETE FROM authorisation_code WHERE client_id=?", + params) + cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params) + cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params) + return the_client diff --git a/gn3/auth/authorisation/oauth2/oauth2token.py b/gn3/auth/authorisation/oauth2/oauth2token.py new file mode 100644 index 0000000..bb19039 --- /dev/null +++ b/gn3/auth/authorisation/oauth2/oauth2token.py @@ -0,0 +1,133 @@ +"""OAuth2 Token""" +import uuid +import datetime +from typing import NamedTuple, Optional + +from pymonad.maybe import Just, Maybe, Nothing + +from gn3.auth import db + +from gn3.auth.authorisation.errors import NotFoundError +from gn3.auth.authorisation.users import User, user_by_id + +from .oauth2client import client, OAuth2Client + +class OAuth2Token(NamedTuple): + """Implement Tokens for OAuth2.""" + token_id: uuid.UUID + client: OAuth2Client + token_type: str + access_token: str + refresh_token: Optional[str] + scope: str + revoked: bool + issued_at: datetime.datetime + expires_in: int + user: User + + @property + def expires_at(self) -> datetime.datetime: + """Return the time when the token expires.""" + return self.issued_at + datetime.timedelta(seconds=self.expires_in) + + def check_client(self, client: OAuth2Client) -> bool:# pylint: disable=[redefined-outer-name] + """Check whether the token is issued to given `client`.""" + return client.client_id == self.client.client_id + + def get_expires_in(self) -> int: + """Return the `expires_in` value for the token.""" + return self.expires_in + + def get_scope(self) -> str: + """Return the valid scope for the token.""" + return self.scope + + def is_expired(self) -> bool: + """Check whether the token is expired.""" + return self.expires_at < datetime.datetime.now() + + def is_revoked(self): + """Check whether the token has been revoked.""" + return self.revoked + +def __token_from_resultset__(conn: db.DbConnection, rset) -> Maybe: + def __identity__(val): + return val + try: + the_user = user_by_id(conn, uuid.UUID(rset["user_id"])) + except NotFoundError as _nfe: + the_user = None + the_client = client(conn, uuid.UUID(rset["client_id"]), the_user) + + if the_client.is_just() and bool(the_user): + return Just(OAuth2Token(token_id=uuid.UUID(rset["token_id"]), + client=the_client.maybe(None, __identity__), + token_type=rset["token_type"], + access_token=rset["access_token"], + refresh_token=rset["refresh_token"], + scope=rset["scope"], + revoked=(rset["revoked"] == 1), + issued_at=datetime.datetime.fromtimestamp( + rset["issued_at"]), + expires_in=rset["expires_in"], + user=the_user))# type: ignore[arg-type] + + return Nothing + +def token_by_access_token(conn: db.DbConnection, token_str: str) -> Maybe: + """Retrieve token by its token string""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM oauth2_tokens WHERE access_token=?", + (token_str,)) + res = cursor.fetchone() + if res: + return __token_from_resultset__(conn, res) + + return Nothing + +def token_by_refresh_token(conn: db.DbConnection, token_str: str) -> Maybe: + """Retrieve token by its token string""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT * FROM oauth2_tokens WHERE refresh_token=?", + (token_str,)) + res = cursor.fetchone() + if res: + return __token_from_resultset__(conn, res) + + return Nothing + +def revoke_token(token: OAuth2Token) -> OAuth2Token: + """ + Return a new token derived from `token` with the `revoked` field set to + `True`. + """ + return OAuth2Token( + token_id=token.token_id, client=token.client, + token_type=token.token_type, access_token=token.access_token, + refresh_token=token.refresh_token, scope=token.scope, revoked=True, + issued_at=token.issued_at, expires_in=token.expires_in, user=token.user) + +def save_token(conn: db.DbConnection, token: OAuth2Token) -> None: + """Save/Update the token.""" + with db.cursor(conn) as cursor: + cursor.execute( + ("INSERT INTO oauth2_tokens VALUES (:token_id, :client_id, " + ":token_type, :access_token, :refresh_token, :scope, :revoked, " + ":issued_at, :expires_in, :user_id) " + "ON CONFLICT (token_id) DO UPDATE SET " + "refresh_token=:refresh_token, revoked=:revoked, " + "expires_in=:expires_in " + "WHERE token_id=:token_id"), + { + "token_id": str(token.token_id), + "client_id": str(token.client.client_id), + "token_type": token.token_type, + "access_token": token.access_token, + "refresh_token": token.refresh_token, + "scope": token.scope, + "revoked": 1 if token.revoked else 0, + "issued_at": int(token.issued_at.timestamp()), + "expires_in": token.expires_in, + "user_id": str(token.user.user_id) + }) diff --git a/gn3/auth/authorisation/oauth2/resource_server.py b/gn3/auth/authorisation/oauth2/resource_server.py new file mode 100644 index 0000000..e806dc5 --- /dev/null +++ b/gn3/auth/authorisation/oauth2/resource_server.py @@ -0,0 +1,19 @@ +"""Protect the resources endpoints""" + +from flask import current_app as app +from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator +from authlib.integrations.flask_oauth2 import ResourceProtector + +from gn3.auth import db +from gn3.auth.authorisation.oauth2.oauth2token import token_by_access_token + +class BearerTokenValidator(_BearerTokenValidator): + """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`""" + def authenticate_token(self, token_string: str): + with db.connection(app.config["AUTH_DB"]) as conn: + return token_by_access_token(conn, token_string).maybe(# type: ignore[misc] + None, lambda tok: tok) + +require_oauth = ResourceProtector() + +require_oauth.register_token_validator(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/", 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/", 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/authorisation/users/base.py b/gn3/auth/authorisation/users/base.py new file mode 100644 index 0000000..0e72ed2 --- /dev/null +++ b/gn3/auth/authorisation/users/base.py @@ -0,0 +1,128 @@ +"""User-specific code and data structures.""" +from uuid import UUID, uuid4 +from typing import Any, Tuple, NamedTuple + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +from gn3.auth import db +from gn3.auth.authorisation.errors import NotFoundError + +class User(NamedTuple): + """Class representing a user.""" + user_id: UUID + email: str + name: str + + def get_user_id(self): + """Return the user's UUID. Mostly for use with Authlib.""" + return self.user_id + + def dictify(self) -> dict[str, Any]: + """Return a dict representation of `User` objects.""" + return {"user_id": self.user_id, "email": self.email, "name": self.name} + +DUMMY_USER = User(user_id=UUID("a391cf60-e8b7-4294-bd22-ddbbda4b3530"), + email="gn3@dummy.user", + name="Dummy user to use as placeholder") + +def user_by_email(conn: db.DbConnection, email: str) -> User: + """Retrieve user from database by their email address""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM users WHERE email=?", (email,)) + row = cursor.fetchone() + + if row: + return User(UUID(row["user_id"]), row["email"], row["name"]) + + raise NotFoundError(f"Could not find user with email {email}") + +def user_by_id(conn: db.DbConnection, user_id: UUID) -> User: + """Retrieve user from database by their user id""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM users WHERE user_id=?", (str(user_id),)) + row = cursor.fetchone() + + if row: + return User(UUID(row["user_id"]), row["email"], row["name"]) + + raise NotFoundError(f"Could not find user with ID {user_id}") + +def same_password(password: str, hashed: str) -> bool: + """Check that `raw_password` is hashed to `hash`""" + try: + return hasher().verify(hashed, password) + except VerifyMismatchError as _vme: + return False + +def valid_login(conn: db.DbConnection, user: User, password: str) -> bool: + """Check the validity of the provided credentials for login.""" + with db.cursor(conn) as cursor: + cursor.execute( + ("SELECT * FROM users LEFT JOIN user_credentials " + "ON users.user_id=user_credentials.user_id " + "WHERE users.user_id=?"), + (str(user.user_id),)) + row = cursor.fetchone() + + if row is None: + return False + + return same_password(password, row["password"]) + +def save_user(cursor: db.DbCursor, email: str, name: str) -> User: + """ + Create and persist a user. + + The user creation could be done during a transaction, therefore the function + takes a cursor object rather than a connection. + + The newly created and persisted user is then returned. + """ + user_id = uuid4() + cursor.execute("INSERT INTO users VALUES (?, ?, ?)", + (str(user_id), email, name)) + return User(user_id, email, name) + +def hasher(): + """Retrieve PasswordHasher object""" + # TODO: Maybe tune the parameters here... + # Tuneable Parameters: + # - time_cost (default: 2) + # - memory_cost (default: 102400) + # - parallelism (default: 8) + # - hash_len (default: 16) + # - salt_len (default: 16) + # - encoding (default: 'utf-8') + # - type (default: ) + return PasswordHasher() + +def hash_password(password): + """Hash the password.""" + return hasher().hash(password) + +def set_user_password( + cursor: db.DbCursor, user: User, password: str) -> Tuple[User, bytes]: + """Set the given user's password in the database.""" + hashed_password = hash_password(password) + cursor.execute( + ("INSERT INTO user_credentials VALUES (:user_id, :hash) " + "ON CONFLICT (user_id) DO UPDATE SET password=:hash"), + {"user_id": str(user.user_id), "hash": hashed_password}) + return user, hashed_password + +def users(conn: db.DbConnection, + ids: tuple[UUID, ...] = tuple()) -> tuple[User, ...]: + """ + Fetch all users with the given `ids`. If `ids` is empty, return ALL users. + """ + params = ", ".join(["?"] * len(ids)) + with db.cursor(conn) as cursor: + query = "SELECT * FROM users" + ( + f" WHERE user_id IN ({params})" + if len(ids) > 0 else "") + print(query) + cursor.execute(query, tuple(str(the_id) for the_id in ids)) + return tuple(User(UUID(row["user_id"]), row["email"], row["name"]) + for row in cursor.fetchall()) + return tuple() 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"]) -- cgit v1.2.3