From 8b7c598407a5fea9a3d78473e72df87606998cd4 Mon Sep 17 00:00:00 2001 From: Frederick Muriuki Muriithi Date: Fri, 4 Aug 2023 10:10:28 +0300 Subject: Copy over files from GN3 repository. --- gn_auth/auth/authentication/__init__.py | 24 +++ gn_auth/auth/authentication/exceptions.py | 4 + gn_auth/auth/authentication/oauth2/__init__.py | 0 .../authentication/oauth2/endpoints/__init__.py | 0 .../oauth2/endpoints/introspection.py | 48 +++++ .../authentication/oauth2/endpoints/revocation.py | 22 ++ .../authentication/oauth2/endpoints/utilities.py | 30 +++ .../auth/authentication/oauth2/grants/__init__.py | 0 .../oauth2/grants/authorisation_code_grant.py | 85 ++++++++ .../authentication/oauth2/grants/password_grant.py | 22 ++ .../auth/authentication/oauth2/models/__init__.py | 0 .../oauth2/models/authorization_code.py | 93 ++++++++ .../authentication/oauth2/models/oauth2client.py | 234 +++++++++++++++++++++ .../authentication/oauth2/models/oauth2token.py | 132 ++++++++++++ .../auth/authentication/oauth2/resource_server.py | 19 ++ gn_auth/auth/authentication/oauth2/server.py | 72 +++++++ gn_auth/auth/authentication/oauth2/views.py | 104 +++++++++ gn_auth/auth/authentication/users.py | 128 +++++++++++ 18 files changed, 1017 insertions(+) create mode 100644 gn_auth/auth/authentication/__init__.py create mode 100644 gn_auth/auth/authentication/exceptions.py create mode 100644 gn_auth/auth/authentication/oauth2/__init__.py create mode 100644 gn_auth/auth/authentication/oauth2/endpoints/__init__.py create mode 100644 gn_auth/auth/authentication/oauth2/endpoints/introspection.py create mode 100644 gn_auth/auth/authentication/oauth2/endpoints/revocation.py create mode 100644 gn_auth/auth/authentication/oauth2/endpoints/utilities.py create mode 100644 gn_auth/auth/authentication/oauth2/grants/__init__.py create mode 100644 gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py create mode 100644 gn_auth/auth/authentication/oauth2/grants/password_grant.py create mode 100644 gn_auth/auth/authentication/oauth2/models/__init__.py create mode 100644 gn_auth/auth/authentication/oauth2/models/authorization_code.py create mode 100644 gn_auth/auth/authentication/oauth2/models/oauth2client.py create mode 100644 gn_auth/auth/authentication/oauth2/models/oauth2token.py create mode 100644 gn_auth/auth/authentication/oauth2/resource_server.py create mode 100644 gn_auth/auth/authentication/oauth2/server.py create mode 100644 gn_auth/auth/authentication/oauth2/views.py create mode 100644 gn_auth/auth/authentication/users.py (limited to 'gn_auth/auth/authentication') diff --git a/gn_auth/auth/authentication/__init__.py b/gn_auth/auth/authentication/__init__.py new file mode 100644 index 0000000..42ceacb --- /dev/null +++ b/gn_auth/auth/authentication/__init__.py @@ -0,0 +1,24 @@ +"""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/gn_auth/auth/authentication/exceptions.py b/gn_auth/auth/authentication/exceptions.py new file mode 100644 index 0000000..c31e691 --- /dev/null +++ b/gn_auth/auth/authentication/exceptions.py @@ -0,0 +1,4 @@ +"""Exceptions for authentication""" + +class AuthenticationError(Exception): + """Base exception class for `gn3.auth.authentication` package.""" diff --git a/gn_auth/auth/authentication/oauth2/__init__.py b/gn_auth/auth/authentication/oauth2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gn_auth/auth/authentication/oauth2/endpoints/__init__.py b/gn_auth/auth/authentication/oauth2/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py new file mode 100644 index 0000000..a567363 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py @@ -0,0 +1,48 @@ +"""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:# pylint: disable=[no-self-use] + """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):# pylint: disable=[unused-argument, no-self-use] + """Check that the client has permission to introspect token.""" + return client.client_type == "internal" diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py new file mode 100644 index 0000000..b8517b6 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py @@ -0,0 +1,22 @@ +"""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/gn_auth/auth/authentication/oauth2/endpoints/utilities.py b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py new file mode 100644 index 0000000..299f151 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py @@ -0,0 +1,30 @@ +"""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.""" + __identity__ = lambda val: 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/gn_auth/auth/authentication/oauth2/grants/__init__.py b/gn_auth/auth/authentication/oauth2/grants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py b/gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py new file mode 100644 index 0000000..f80d02e --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py @@ -0,0 +1,85 @@ +"""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):# pylint: disable=[no-self-use] + """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/gn_auth/auth/authentication/oauth2/grants/password_grant.py b/gn_auth/auth/authentication/oauth2/grants/password_grant.py new file mode 100644 index 0000000..3233877 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/grants/password_grant.py @@ -0,0 +1,22 @@ +"""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/gn_auth/auth/authentication/oauth2/models/__init__.py b/gn_auth/auth/authentication/oauth2/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gn_auth/auth/authentication/oauth2/models/authorization_code.py b/gn_auth/auth/authentication/oauth2/models/authorization_code.py new file mode 100644 index 0000000..f282814 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/authorization_code.py @@ -0,0 +1,93 @@ +"""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/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py new file mode 100644 index 0000000..2a307e3 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/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.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/gn_auth/auth/authentication/oauth2/models/oauth2token.py b/gn_auth/auth/authentication/oauth2/models/oauth2token.py new file mode 100644 index 0000000..72e20cc --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/oauth2token.py @@ -0,0 +1,132 @@ +"""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: + __identity__ = lambda val: 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/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py new file mode 100644 index 0000000..223e811 --- /dev/null +++ b/gn_auth/auth/authentication/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.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/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py new file mode 100644 index 0000000..7d7113a --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/server.py @@ -0,0 +1,72 @@ +"""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/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py new file mode 100644 index 0000000..2bd3865 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/views.py @@ -0,0 +1,104 @@ +"""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/", 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/gn_auth/auth/authentication/users.py b/gn_auth/auth/authentication/users.py new file mode 100644 index 0000000..0e72ed2 --- /dev/null +++ b/gn_auth/auth/authentication/users.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() -- cgit v1.2.3