diff options
25 files changed, 702 insertions, 63 deletions
@@ -168,13 +168,17 @@ This expects that the following two configuration variables are set in the appli To run tests: ```bash -pytest +$ export AUTHLIB_INSECURE_TRANSPORT=true +$ export OAUTH2_ACCESS_TOKEN_GENERATOR="tests.unit.auth.test_token.gen_token" +$ pytest ``` To specify unit-tests: ```bash -pytest -k unit_test +$ export AUTHLIB_INSECURE_TRANSPORT=true +$ export OAUTH2_ACCESS_TOKEN_GENERATOR="tests.unit.auth.test_token.gen_token" +$ pytest -k unit_test ``` Running pylint: @@ -19,6 +19,8 @@ from gn3.api.async_commands import async_commands from gn3.api.menu import menu from gn3.api.search import search from gn3.api.metadata import metadata +from gn3.auth.authentication.oauth2.views import oauth2 +from gn3.auth.authentication.oauth2.server import setup_oauth2_server def create_app(config: Union[Dict, str, None] = None) -> Flask: @@ -56,4 +58,7 @@ def create_app(config: Union[Dict, str, None] = None) -> Flask: app.register_blueprint(menu, url_prefix="/api/menu") app.register_blueprint(search, url_prefix="/api/search") app.register_blueprint(metadata, url_prefix="/api/metadata") + app.register_blueprint(oauth2, url_prefix="/api/oauth2") + + setup_oauth2_server(app) return app diff --git a/gn3/auth/authentication/oauth2/__init__.py b/gn3/auth/authentication/oauth2/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn3/auth/authentication/oauth2/__init__.py diff --git a/gn3/auth/authentication/oauth2/endpoints/__init__.py b/gn3/auth/authentication/oauth2/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn3/auth/authentication/oauth2/endpoints/__init__.py diff --git a/gn3/auth/authentication/oauth2/endpoints/introspection.py b/gn3/auth/authentication/oauth2/endpoints/introspection.py new file mode 100644 index 0000000..a567363 --- /dev/null +++ b/gn3/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/gn3/auth/authentication/oauth2/endpoints/revocation.py b/gn3/auth/authentication/oauth2/endpoints/revocation.py new file mode 100644 index 0000000..0693c2d --- /dev/null +++ b/gn3/auth/authentication/oauth2/endpoints/revocation.py @@ -0,0 +1,21 @@ +"""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""" + 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 new file mode 100644 index 0000000..299f151 --- /dev/null +++ b/gn3/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/gn3/auth/authentication/oauth2/grants/__init__.py b/gn3/auth/authentication/oauth2/grants/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn3/auth/authentication/oauth2/grants/__init__.py diff --git a/gn3/auth/authentication/oauth2/grants/authorisation_code_grant.py b/gn3/auth/authentication/oauth2/grants/authorisation_code_grant.py new file mode 100644 index 0000000..d398192 --- /dev/null +++ b/gn3/auth/authentication/oauth2/grants/authorisation_code_grant.py @@ -0,0 +1,45 @@ +"""Classes and function for Authorisation Code flow.""" +import uuid +from typing import Optional + +from flask import current_app as app +from authlib.oauth2.rfc6749 import grants + +from gn3.auth import db +from gn3.auth.authentication.users import User + +class AuthorisationCodeGrant(grants.AuthorizationCodeGrant): + """Implement the 'Authorisation Code' grant.""" + TOKEN_ENDPOINT_AUTH_METHODS = ["client_secret_basic", "client_secret_post"] + + def save_authorization_code(self, code, request): + """Persist the authorisation code to database.""" + raise Exception("NOT IMPLEMENTED!", self, code, request) + + def query_authorization_code(self, code, client): + """Retrieve the code from the database.""" + raise Exception("NOT IMPLEMENTED!", self, 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.user_id),)) + res = cursor.fetchone() + if res: + return User( + uuid.UUID(res["user_id"]), res["email"], res["name"]) + + return None diff --git a/gn3/auth/authentication/oauth2/grants/password_grant.py b/gn3/auth/authentication/oauth2/grants/password_grant.py new file mode 100644 index 0000000..91fdb7c --- /dev/null +++ b/gn3/auth/authentication/oauth2/grants/password_grant.py @@ -0,0 +1,18 @@ +"""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 + +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: + return user_by_email(conn, username).maybe( + None, + lambda user: valid_login(conn, user, password)) diff --git a/gn3/auth/authentication/oauth2/models/__init__.py b/gn3/auth/authentication/oauth2/models/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn3/auth/authentication/oauth2/models/__init__.py diff --git a/gn3/auth/authentication/oauth2/models/oauth2client.py b/gn3/auth/authentication/oauth2/models/oauth2client.py new file mode 100644 index 0000000..2ee7858 --- /dev/null +++ b/gn3/auth/authentication/oauth2/models/oauth2client.py @@ -0,0 +1,141 @@ +"""OAuth2 Client model.""" +import json +import uuid +import datetime +from typing import NamedTuple, Sequence + +from pymonad.maybe import Just, Maybe, Nothing + +from gn3.auth import db +from gn3.auth.authentication.users import User, user_by_id + +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.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 self.client_secret == 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.""" + 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_types", []) + + 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.UUID) -> 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() + if result: + return Just( + OAuth2Client(uuid.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"]), + user_by_id( # type: ignore[misc] + conn, uuid.UUID(result["user_id"])).maybe( + None, lambda usr: usr))) + + return Nothing diff --git a/gn3/auth/authentication/oauth2/models/oauth2token.py b/gn3/auth/authentication/oauth2/models/oauth2token.py new file mode 100644 index 0000000..70421b4 --- /dev/null +++ b/gn3/auth/authentication/oauth2/models/oauth2token.py @@ -0,0 +1,119 @@ +"""OAuth2 Token""" +import uuid +import datetime +from typing import NamedTuple, Optional, Sequence + +from pymonad.maybe import Just, Maybe, Nothing + +from gn3.auth import db +from gn3.auth.authentication.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: Sequence[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 " ".join(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: + the_client = client(conn, uuid.UUID(rset["client_id"])) + the_user = user_by_id(conn, uuid.UUID(rset["user_id"])) + __identity__ = lambda val: val + + if the_client.is_just() and the_user.is_just(): + 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"].split(None), + revoked=(rset["revoked"] == 1), + issued_at=datetime.datetime.fromtimestamp( + rset["issued_at"]), + expires_in=rset["expires_in"], + user=the_user.maybe(None, __identity__))) + + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (str(token.token_id), str(token.client.client_id), token.token_type, + token.access_token, token.refresh_token, token.scope, + 1 if token.revoked else 0, int(token.issued_at.timestamp()), + token.expires_in, str(token.user.user_id))) + ## If already exists + # cursor.execute( + # ("UPDATE oauth2_tokens SET refresh_token=?, revoked=?, " + # "expires_in=? WHERE token_id=?"), + # (token.refresh_token, token.scope, 1 if token.revoked else 0, + # token.expires_in, str(token.token_id))) diff --git a/gn3/auth/authentication/oauth2/server.py b/gn3/auth/authentication/oauth2/server.py new file mode 100644 index 0000000..960625d --- /dev/null +++ b/gn3/auth/authentication/oauth2/server.py @@ -0,0 +1,63 @@ +"""Initialise the OAuth2 Server""" +import uuid +import datetime +from typing import Callable + +from flask import Flask, current_app +from authlib.integrations.flask_oauth2 import AuthorizationServer +# from authlib.integrations.sqla_oauth2 import ( +# create_save_token_func, create_query_client_func) + +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: + return client(conn, client_id).maybe(None, lambda clt: clt) # type: ignore[misc] + + 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.client.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) + # 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 new file mode 100644 index 0000000..58fa6d4 --- /dev/null +++ b/gn3/auth/authentication/oauth2/views.py @@ -0,0 +1,42 @@ +"""Endpoints for the oauth2 server""" +import uuid + +from flask import Blueprint, current_app as app + +from .endpoints.revocation import RevocationEndpoint +from .endpoints.introspection import IntrospectionEndpoint + +oauth2 = Blueprint("oauth2", __name__) + +@oauth2.route("/register-client", methods=["GET", "POST"]) +def register_client(): + """Register an OAuth2 client.""" + return "WOULD REGISTER ..." + +@oauth2.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}." + +@oauth2.route("/authorise", methods=["GET", "POST"]) +def authorise(): + """Authorise a user""" + return "WOULD AUTHORISE THE USER." + +@oauth2.route("/token", methods=["POST"]) +def token(): + """Retrieve the authorisation token.""" + server = app.config["OAUTH2_SERVER"] + return server.create_token_response() + +@oauth2.route("/revoke", methods=["POST"]) +def revoke_token(): + """Revoke the token.""" + return app.config["OAUTH2_SERVER"].create_endpoint_response( + RevocationEndpoint.ENDPOINT_NAME) + +@oauth2.route("/introspect", methods=["POST"]) +def introspect_token(): + """Provide introspection information for the token.""" + return app.config["OAUTH2_SERVER"].create_endpoint_response( + IntrospectionEndpoint.ENDPOINT_NAME) diff --git a/gn3/auth/authentication/routes.py b/gn3/auth/authentication/routes.py deleted file mode 100644 index 3b288d7..0000000 --- a/gn3/auth/authentication/routes.py +++ /dev/null @@ -1,57 +0,0 @@ -import requests - -import bcrypt -from flask import flash, jsonify, request, session, Blueprint - -from gn3.auth import db -from gn3.settings import AUTH_DB - -from .users import User, user_by_email - -auth_routes = Blueprint("auth", __name__) - -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 == None: - return False - - return bcrypt.checkpw(password.encode("utf-8"), row["password"]) - -@auth_routes.route("/login", methods=["POST"]) -def login(): - """Log in the user.""" - print(request.cookies) - if session.get("user"): - flash("Already logged in!", "alert-warning") - print(f"ALREADY LOGGED IN: {session['user']}") - return redirect("/", code=302) - - form = request.form - email = form.get("email").strip() - password = form.get("password").strip() - if email == "" or password == "": - flash("You must provide the email and password!", "alert-error") - return redirect("/", code=302) - - with db.connection(AUTH_DB) as conn: - user = user_by_email(conn, email).maybe(False, lambda usr: usr) - if user and valid_login(conn, user, password): - session["user"] = user - return jsonify({ - "user_id": user.user_id, - "email": user.email, - "name": user.name - }), 200 - - return jsonify({ - "message": "Could not login. Invalid 'email' or 'password'.", - "type": "authentication-error" - }), 401 diff --git a/gn3/auth/authentication/users.py b/gn3/auth/authentication/users.py index 11deba2..6ec6bca 100644 --- a/gn3/auth/authentication/users.py +++ b/gn3/auth/authentication/users.py @@ -2,6 +2,7 @@ from uuid import UUID from typing import NamedTuple +import bcrypt from pymonad.maybe import Just, Maybe, Nothing from gn3.auth import db @@ -17,6 +18,7 @@ class User(NamedTuple): return self.user_id def user_by_email(conn: db.DbConnection, email: str) -> Maybe: + """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() @@ -25,3 +27,29 @@ def user_by_email(conn: db.DbConnection, email: str) -> Maybe: return Just(User(UUID(row["user_id"]), row["email"], row["name"])) return Nothing + +def user_by_id(conn: db.DbConnection, user_id: UUID) -> Maybe: + """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 Just(User(UUID(row["user_id"]), row["email"], row["name"])) + + return Nothing + +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 bcrypt.checkpw(password.encode("utf-8"), row["password"]) diff --git a/gn3/settings.py b/gn3/settings.py index 5fec562..70af723 100644 --- a/gn3/settings.py +++ b/gn3/settings.py @@ -67,3 +67,7 @@ MULTIPROCESSOR_PROCS = 6 # Number of processes to spawn AUTH_MIGRATIONS = "migrations/auth" AUTH_DB = os.environ.get( "AUTH_DB", f"{os.environ.get('HOME')}/genenetwork/gn3_files/db/auth.db") + +## OAuth2 Settings +OAUTH2_ACCESS_TOKEN_GENERATOR = os.environ.get( + "OAUTH2_ACCESS_TOKEN_GENERATOR", True) @@ -51,3 +51,6 @@ ignore_missing_imports = True [mypy-yoyo.*] ignore_missing_imports = True + +[mypy-authlib.*] +ignore_missing_imports = True diff --git a/tests/unit/auth/fixtures/__init__.py b/tests/unit/auth/fixtures/__init__.py index 7adae3f..a675fc7 100644 --- a/tests/unit/auth/fixtures/__init__.py +++ b/tests/unit/auth/fixtures/__init__.py @@ -5,3 +5,4 @@ from .group_fixtures import * from .resource_fixtures import * # from .privilege_fixtures import * from .migration_fixtures import * +from .oauth2_client_fixtures import * diff --git a/tests/unit/auth/fixtures/oauth2_client_fixtures.py b/tests/unit/auth/fixtures/oauth2_client_fixtures.py new file mode 100644 index 0000000..751eadd --- /dev/null +++ b/tests/unit/auth/fixtures/oauth2_client_fixtures.py @@ -0,0 +1,44 @@ +"""Fixtures for OAuth2 clients""" +import uuid +import json +import datetime + +import pytest + +from gn3.auth import db +from gn3.auth.authentication.oauth2.models.oauth2client import OAuth2Client + +@pytest.fixture +def fixture_oauth2_clients(fixture_users_with_passwords): + """Fixture: Create the OAuth2 clients for use with tests.""" + conn, users = fixture_users_with_passwords + now = datetime.datetime.now() + + clients = tuple( + OAuth2Client(str(uuid.uuid4()), f"yabadabadoo_{idx:03}", now, + now + datetime.timedelta(hours = 2), + { + "client_name": f"test_client_{idx:03}", + "scope": ["user", "profile"], + "redirect_uri": "/test_oauth2", + "token_endpoint_auth_method": [ + "client_secret_post", "client_secret_basic"], + "grant_types": ["password"] + }, user) + for idx, user in enumerate(users, start=1)) + + with db.cursor(conn) as cursor: + cursor.executemany( + "INSERT INTO oauth2_clients VALUES (?, ?, ?, ?, ?, ?)", + ((str(client.client_id), client.client_secret, + int(client.client_id_issued_at.timestamp()), + int(client.client_secret_expires_at.timestamp()), + json.dumps(client.client_metadata), str(client.user.user_id)) + for client in clients)) + + yield conn, clients + + with db.cursor(conn) as cursor: + cursor.executemany( + "DELETE FROM oauth2_clients WHERE client_id=?", + ((str(client.client_id),) for client in clients)) diff --git a/tests/unit/auth/fixtures/user_fixtures.py b/tests/unit/auth/fixtures/user_fixtures.py index cc43a74..843d575 100644 --- a/tests/unit/auth/fixtures/user_fixtures.py +++ b/tests/unit/auth/fixtures/user_fixtures.py @@ -2,6 +2,7 @@ import uuid import pytest +import bcrypt from gn3.auth import db from gn3.auth.authentication.users import User @@ -41,3 +42,25 @@ def test_users(conn_after_auth_migrations):# pylint: disable=[redefined-outer-na ("21351b66-8aad-475b-84ac-53ce528451e3",), ("ae9c6245-0966-41a5-9a5e-20885a96bea7",), ("9a0c7ce5-2f40-4e78-979e-bf3527a59579",))) + +@pytest.fixture(scope="function") +def fixture_users_with_passwords(test_users): # pylint: disable=[redefined-outer-name] + """Fixture: add passwords to the users""" + conn, users = test_users + user_passwords_params = tuple( + (str(user.user_id), bcrypt.hashpw( + f"password_for_user_{idx:03}".encode("utf8"), + bcrypt.gensalt())) + for idx, user in enumerate(users, start=1)) + + with db.cursor(conn) as cursor: + cursor.executemany( + "INSERT INTO user_credentials VALUES (?, ?)", + user_passwords_params) + + yield conn, users + + with db.cursor(conn) as cursor: + cursor.executemany( + "DELETE FROM user_credentials WHERE user_id=?", + ((item[0],) for item in user_passwords_params)) diff --git a/tests/unit/auth/test_migrations_add_data_to_table.py b/tests/unit/auth/test_migrations_add_data_to_table.py index acd1f6f..9cb5d0c 100644 --- a/tests/unit/auth/test_migrations_add_data_to_table.py +++ b/tests/unit/auth/test_migrations_add_data_to_table.py @@ -38,7 +38,7 @@ def test_apply_insert(# pylint: disable=[too-many-arguments] older_migrations = migrations_up_to(migration_path, auth_migrations_dir) the_migration = get_migration(migration_path) apply_migrations(backend, older_migrations) - with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + with db.connection(auth_testdb_path, None) as conn, db.cursor(conn) as cursor: cursor.execute(query, query_params) result_before_migration = cursor.fetchall() apply_single_migration(backend, the_migration) @@ -63,7 +63,7 @@ def test_rollback_insert(# pylint: disable=[too-many-arguments] older_migrations = migrations_up_to(migration_path, auth_migrations_dir) the_migration = get_migration(migration_path) apply_migrations(backend, older_migrations) - with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + with db.connection(auth_testdb_path, None) as conn, db.cursor(conn) as cursor: cursor.execute(query, query_params) result_before_migration = cursor.fetchall() apply_single_migration(backend, the_migration) diff --git a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py index 0e78823..dd3d4c6 100644 --- a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py +++ b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py @@ -20,7 +20,7 @@ def test_apply_init_data(auth_testdb_path, auth_migrations_dir, backend): older_migrations = migrations_up_to(MIGRATION_PATH, auth_migrations_dir) the_migration = get_migration(MIGRATION_PATH) apply_migrations(backend, older_migrations) - with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + with db.connection(auth_testdb_path, None) as conn, db.cursor(conn) as cursor: cursor.execute("SELECT * FROM resource_categories") assert len(cursor.fetchall()) == 0, "Expected empty table." apply_single_migration(backend, the_migration) @@ -46,7 +46,7 @@ def test_rollback_init_data(auth_testdb_path, auth_migrations_dir, backend): older_migrations = migrations_up_to(MIGRATION_PATH, auth_migrations_dir) the_migration = get_migration(MIGRATION_PATH) apply_migrations(backend, older_migrations) - with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + with db.connection(auth_testdb_path, None) as conn, db.cursor(conn) as cursor: cursor.execute("SELECT * FROM resource_categories") assert len(cursor.fetchall()) == 0, "Expected empty table." apply_single_migration(backend, the_migration) diff --git a/tests/unit/auth/test_token.py b/tests/unit/auth/test_token.py new file mode 100644 index 0000000..edf4b19 --- /dev/null +++ b/tests/unit/auth/test_token.py @@ -0,0 +1,57 @@ +"""Test the OAuth2 authorisation""" + +import pytest + +SUCCESS_RESULT = { + "status_code": 200, + "result": { + "access_token": "123456ABCDE", + "expires_in": 864000, + "scope": "profile", + "token_type": "Bearer"}} + +USERNAME_PASSWORD_FAIL_RESULT = { + "status_code": 400, + "result": { + 'error': 'invalid_request', + 'error_description': 'Invalid "username" or "password" in request.'}} + +def gen_token(client, grant_type, user, scope): # pylint: disable=[unused-argument] + """Generate tokens for tests""" + return "123456ABCDE" + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "test_data,expected", + ((("group@lead.er", "password_for_user_001", 0), SUCCESS_RESULT), + (("group@mem.ber01", "password_for_user_002", 1), SUCCESS_RESULT), + (("group@mem.ber02", "password_for_user_003", 2), SUCCESS_RESULT), + (("unaff@iliated.user", "password_for_user_004", 3), SUCCESS_RESULT), + (("group@lead.er", "brrr", 0), USERNAME_PASSWORD_FAIL_RESULT), + (("group@mem.ber010", "password_for_user_002", 1), USERNAME_PASSWORD_FAIL_RESULT), + (("papa", "yada", 2), USERNAME_PASSWORD_FAIL_RESULT), + # (("unaff@iliated.user", "password_for_user_004", 1), USERNAME_PASSWORD_FAIL_RESULT) + )) +def test_token(test_app, fixture_oauth2_clients, test_data, expected): + """ + GIVEN: a registered oauth2 client, a user + WHEN: a token is requested via the 'password' grant + THEN: check that: + a) when email and password are valid, we get a token back + b) when either email or password or both are invalid, we get error message + back + c) TODO: when user tries to use wrong client, we get error message back + """ + _conn, oa2clients = fixture_oauth2_clients + email, password, client_idx = test_data + data = { + "grant_type": "password", "scope": "profile nonexistent-scope", + "client_id": oa2clients[client_idx].client_id, + "client_secret": oa2clients[client_idx].client_secret, + "username": email, "password": password} + + with test_app.test_client() as client: + res = client.post("/api/oauth2/token", data=data) + assert res.status_code == expected["status_code"] + for key in expected["result"]: + assert res.json[key] == expected["result"][key] |