aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--gn3/app.py5
-rw-r--r--gn3/auth/authentication/oauth2/__init__.py0
-rw-r--r--gn3/auth/authentication/oauth2/endpoints/__init__.py0
-rw-r--r--gn3/auth/authentication/oauth2/endpoints/introspection.py48
-rw-r--r--gn3/auth/authentication/oauth2/endpoints/revocation.py21
-rw-r--r--gn3/auth/authentication/oauth2/endpoints/utilities.py30
-rw-r--r--gn3/auth/authentication/oauth2/grants/__init__.py0
-rw-r--r--gn3/auth/authentication/oauth2/grants/authorisation_code_grant.py45
-rw-r--r--gn3/auth/authentication/oauth2/grants/password_grant.py18
-rw-r--r--gn3/auth/authentication/oauth2/models/__init__.py0
-rw-r--r--gn3/auth/authentication/oauth2/models/oauth2client.py141
-rw-r--r--gn3/auth/authentication/oauth2/models/oauth2token.py119
-rw-r--r--gn3/auth/authentication/oauth2/server.py63
-rw-r--r--gn3/auth/authentication/oauth2/views.py42
-rw-r--r--gn3/auth/authentication/routes.py57
-rw-r--r--gn3/auth/authentication/users.py28
-rw-r--r--gn3/settings.py4
-rw-r--r--mypy.ini3
-rw-r--r--tests/unit/auth/fixtures/__init__.py1
-rw-r--r--tests/unit/auth/fixtures/oauth2_client_fixtures.py44
-rw-r--r--tests/unit/auth/fixtures/user_fixtures.py23
-rw-r--r--tests/unit/auth/test_migrations_add_data_to_table.py4
-rw-r--r--tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py4
-rw-r--r--tests/unit/auth/test_token.py57
25 files changed, 702 insertions, 63 deletions
diff --git a/README.md b/README.md
index 27326ba..377283c 100644
--- a/README.md
+++ b/README.md
@@ -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:
diff --git a/gn3/app.py b/gn3/app.py
index a187e54..b776351 100644
--- a/gn3/app.py
+++ b/gn3/app.py
@@ -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)
diff --git a/mypy.ini b/mypy.ini
index 89c5f30..dfedb13 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -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]