diff options
author | Frederick Muriuki Muriithi | 2023-09-07 15:49:00 +0300 |
---|---|---|
committer | Frederick Muriuki Muriithi | 2023-10-10 11:12:40 +0300 |
commit | 0a8279891190e49867d3a1d72db0f7c7cd275646 (patch) | |
tree | 9acceecfcf2667abeaac743e4c7f5139fd5e0afd /gn3/auth/authentication | |
parent | e4af0bbac585b46a5d6303d752cea18ca527d676 (diff) | |
download | genenetwork3-0a8279891190e49867d3a1d72db0f7c7cd275646.tar.gz |
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.
Diffstat (limited to 'gn3/auth/authentication')
18 files changed, 0 insertions, 1019 deletions
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/models/oauth2client.py b/gn3/auth/authentication/oauth2/models/oauth2client.py deleted file mode 100644 index 2a307e3..0000000 --- a/gn3/auth/authentication/oauth2/models/oauth2client.py +++ /dev/null @@ -1,234 +0,0 @@ -"""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.authentication.users import User, users, user_by_id, same_password - -from gn3.auth.authorisation.errors import NotFoundError - -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/authentication/oauth2/models/oauth2token.py b/gn3/auth/authentication/oauth2/models/oauth2token.py deleted file mode 100644 index bfe4aaf..0000000 --- a/gn3/auth/authentication/oauth2/models/oauth2token.py +++ /dev/null @@ -1,133 +0,0 @@ -"""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.authentication.users import User, user_by_id - -from gn3.auth.authorisation.errors import NotFoundError - -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/authentication/oauth2/resource_server.py b/gn3/auth/authentication/oauth2/resource_server.py deleted file mode 100644 index 223e811..0000000 --- a/gn3/auth/authentication/oauth2/resource_server.py +++ /dev/null @@ -1,19 +0,0 @@ -"""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.authentication.oauth2.models.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/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/authentication/users.py b/gn3/auth/authentication/users.py deleted file mode 100644 index 0e72ed2..0000000 --- a/gn3/auth/authentication/users.py +++ /dev/null @@ -1,128 +0,0 @@ -"""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: <Type.ID: 2>) - 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() |