aboutsummaryrefslogtreecommitdiff
path: root/gn3/auth/authorisation
diff options
context:
space:
mode:
authorFrederick Muriuki Muriithi2023-09-07 15:49:00 +0300
committerFrederick Muriuki Muriithi2023-10-10 11:12:40 +0300
commit0a8279891190e49867d3a1d72db0f7c7cd275646 (patch)
tree9acceecfcf2667abeaac743e4c7f5139fd5e0afd /gn3/auth/authorisation
parente4af0bbac585b46a5d6303d752cea18ca527d676 (diff)
downloadgenenetwork3-0a8279891190e49867d3a1d72db0f7c7cd275646.tar.gz
Remove authentication from GN3
Authentication should be handled by the auth server (gn-auth) and thus, this commit removes code handling user authentication from the GN3 system.
Diffstat (limited to 'gn3/auth/authorisation')
-rw-r--r--gn3/auth/authorisation/checks.py3
-rw-r--r--gn3/auth/authorisation/data/views.py4
-rw-r--r--gn3/auth/authorisation/groups/models.py2
-rw-r--r--gn3/auth/authorisation/groups/views.py5
-rw-r--r--gn3/auth/authorisation/oauth2/oauth2client.py234
-rw-r--r--gn3/auth/authorisation/oauth2/oauth2token.py133
-rw-r--r--gn3/auth/authorisation/oauth2/resource_server.py19
-rw-r--r--gn3/auth/authorisation/privileges.py2
-rw-r--r--gn3/auth/authorisation/resources/checks.py2
-rw-r--r--gn3/auth/authorisation/resources/models.py2
-rw-r--r--gn3/auth/authorisation/resources/views.py4
-rw-r--r--gn3/auth/authorisation/roles/models.py2
-rw-r--r--gn3/auth/authorisation/roles/views.py3
-rw-r--r--gn3/auth/authorisation/users/__init__.py12
-rw-r--r--gn3/auth/authorisation/users/admin/__init__.py2
-rw-r--r--gn3/auth/authorisation/users/admin/ui.py27
-rw-r--r--gn3/auth/authorisation/users/admin/views.py230
-rw-r--r--gn3/auth/authorisation/users/base.py128
-rw-r--r--gn3/auth/authorisation/users/collections/views.py4
-rw-r--r--gn3/auth/authorisation/users/masquerade/__init__.py1
-rw-r--r--gn3/auth/authorisation/users/masquerade/models.py67
-rw-r--r--gn3/auth/authorisation/users/masquerade/views.py48
-rw-r--r--gn3/auth/authorisation/users/models.py2
-rw-r--r--gn3/auth/authorisation/users/views.py9
24 files changed, 545 insertions, 400 deletions
diff --git a/gn3/auth/authorisation/checks.py b/gn3/auth/authorisation/checks.py
index 1c87c02..17daca4 100644
--- a/gn3/auth/authorisation/checks.py
+++ b/gn3/auth/authorisation/checks.py
@@ -5,12 +5,11 @@ from typing import Callable
from flask import request, current_app as app
from gn3.auth import db
+from gn3.auth.authorisation.oauth2.resource_server import require_oauth
from . import privileges as auth_privs
from .errors import InvalidData, AuthorisationError
-from ..authentication.oauth2.resource_server import require_oauth
-
def __system_privileges_in_roles__(conn, user):
"""
This really is a hack since groups are not treated as resources at the
diff --git a/gn3/auth/authorisation/data/views.py b/gn3/auth/authorisation/data/views.py
index 8adf862..81811dd 100644
--- a/gn3/auth/authorisation/data/views.py
+++ b/gn3/auth/authorisation/data/views.py
@@ -29,9 +29,9 @@ from gn3.auth.authorisation.resources.checks import authorised_for
from gn3.auth.authorisation.resources.models import (
user_resources, public_resources, attach_resources_data)
-from gn3.auth.authentication.users import User
-from gn3.auth.authentication.oauth2.resource_server import require_oauth
+from gn3.auth.authorisation.oauth2.resource_server import require_oauth
+from gn3.auth.authorisation.users import User
from gn3.auth.authorisation.data.phenotypes import link_phenotype_data
from gn3.auth.authorisation.data.mrna import link_mrna_data, ungrouped_mrna_data
from gn3.auth.authorisation.data.genotypes import (
diff --git a/gn3/auth/authorisation/groups/models.py b/gn3/auth/authorisation/groups/models.py
index 5a3ae50..7212a78 100644
--- a/gn3/auth/authorisation/groups/models.py
+++ b/gn3/auth/authorisation/groups/models.py
@@ -9,7 +9,7 @@ from pymonad.maybe import Just, Maybe, Nothing
from gn3.auth import db
from gn3.auth.dictify import dictify
-from gn3.auth.authentication.users import User, user_by_id
+from gn3.auth.authorisation.users import User, user_by_id
from ..checks import authorised_p
from ..privileges import Privilege
diff --git a/gn3/auth/authorisation/groups/views.py b/gn3/auth/authorisation/groups/views.py
index 628df36..a849a73 100644
--- a/gn3/auth/authorisation/groups/views.py
+++ b/gn3/auth/authorisation/groups/views.py
@@ -12,6 +12,8 @@ from gn3 import db_utils as gn3db
from gn3.auth.dictify import dictify
from gn3.auth.db_utils import with_db_connection
+from gn3.auth.authorisation.users import User
+from gn3.auth.authorisation.oauth2.resource_server import require_oauth
from .data import link_data_to_group
from .models import (
@@ -28,9 +30,6 @@ from ..checks import authorised_p
from ..privileges import Privilege, privileges_by_ids
from ..errors import InvalidData, NotFoundError, AuthorisationError
-from ...authentication.users import User
-from ...authentication.oauth2.resource_server import require_oauth
-
groups = Blueprint("groups", __name__)
@groups.route("/list", methods=["GET"])
diff --git a/gn3/auth/authorisation/oauth2/oauth2client.py b/gn3/auth/authorisation/oauth2/oauth2client.py
new file mode 100644
index 0000000..dc54a41
--- /dev/null
+++ b/gn3/auth/authorisation/oauth2/oauth2client.py
@@ -0,0 +1,234 @@
+"""OAuth2 Client model."""
+import json
+import datetime
+from uuid import UUID
+from typing import Sequence, Optional, NamedTuple
+
+from pymonad.maybe import Just, Maybe, Nothing
+
+from gn3.auth import db
+
+from gn3.auth.authorisation.errors import NotFoundError
+from gn3.auth.authorisation.users import User, users, user_by_id, same_password
+
+class OAuth2Client(NamedTuple):
+ """
+ Client to the OAuth2 Server.
+
+ This is defined according to the mixin at
+ https://docs.authlib.org/en/latest/specs/rfc6749.html#authlib.oauth2.rfc6749.ClientMixin
+ """
+ client_id: UUID
+ client_secret: str
+ client_id_issued_at: datetime.datetime
+ client_secret_expires_at: datetime.datetime
+ client_metadata: dict
+ user: User
+
+ def check_client_secret(self, client_secret: str) -> bool:
+ """Check whether the `client_secret` matches this client."""
+ return same_password(client_secret, self.client_secret)
+
+ @property
+ def token_endpoint_auth_method(self) -> str:
+ """Return the token endpoint authorisation method."""
+ return self.client_metadata.get("token_endpoint_auth_method", ["none"])
+
+ @property
+ def client_type(self) -> str:
+ """
+ Return the token endpoint authorisation method.
+
+ Acceptable client types:
+ * public: Unable to use registered client secrets, e.g. browsers, apps
+ on mobile devices.
+ * confidential: able to securely authenticate with authorisation server
+ e.g. being able to keep their registered client secret safe.
+ """
+ return self.client_metadata.get("client_type", "public")
+
+ def check_endpoint_auth_method(self, method: str, endpoint: str) -> bool:
+ """
+ Check if the client supports the given method for the given endpoint.
+
+ Acceptable methods:
+ * none: Client is a public client and does not have a client secret
+ * client_secret_post: Client uses the HTTP POST parameters
+ * client_secret_basic: Client uses HTTP Basic
+ """
+ if endpoint == "token":
+ return (method in self.token_endpoint_auth_method
+ and method == "client_secret_post")
+ if endpoint in ("introspection", "revoke"):
+ return (method in self.token_endpoint_auth_method
+ and method == "client_secret_basic")
+ return False
+
+ @property
+ def id(self):# pylint: disable=[invalid-name]
+ """Return the client_id."""
+ return self.client_id
+
+ @property
+ def grant_types(self) -> Sequence[str]:
+ """
+ Return the grant types that this client supports.
+
+ Valid grant types:
+ * authorisation_code
+ * implicit
+ * client_credentials
+ * password
+ """
+ return self.client_metadata.get("grant_types", [])
+
+ def check_grant_type(self, grant_type: str) -> bool:
+ """
+ Validate that client can handle the given grant types
+ """
+ return grant_type in self.grant_types
+
+ @property
+ def redirect_uris(self) -> Sequence[str]:
+ """Return the redirect_uris that this client supports."""
+ return self.client_metadata.get('redirect_uris', [])
+
+ def check_redirect_uri(self, redirect_uri: str) -> bool:
+ """
+ Check whether the given `redirect_uri` is one of the expected ones.
+ """
+ return redirect_uri in self.redirect_uris
+
+ @property
+ def response_types(self) -> Sequence[str]:
+ """Return the response_types that this client supports."""
+ return self.client_metadata.get("response_type", [])
+
+ def check_response_type(self, response_type: str) -> bool:
+ """Check whether this client supports `response_type`."""
+ return response_type in self.response_types
+
+ @property
+ def scope(self) -> Sequence[str]:
+ """Return valid scopes for this client."""
+ return tuple(set(self.client_metadata.get("scope", [])))
+
+ def get_allowed_scope(self, scope: str) -> str:
+ """Return list of scopes in `scope` that are supported by this client."""
+ if not bool(scope):
+ return ""
+ requested = scope.split()
+ return " ".join(sorted(set(
+ scp for scp in requested if scp in self.scope)))
+
+ def get_client_id(self):
+ """Return this client's identifier."""
+ return self.client_id
+
+ def get_default_redirect_uri(self) -> str:
+ """Return the default redirect uri"""
+ return self.client_metadata.get("default_redirect_uri", "")
+
+def client(conn: db.DbConnection, client_id: UUID,
+ user: Optional[User] = None) -> Maybe:
+ """Retrieve a client by its ID"""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT * FROM oauth2_clients WHERE client_id=?", (str(client_id),))
+ result = cursor.fetchone()
+ the_user = user
+ if result:
+ if not bool(the_user):
+ try:
+ the_user = user_by_id(conn, result["user_id"])
+ except NotFoundError as _nfe:
+ the_user = None
+
+ return Just(
+ OAuth2Client(UUID(result["client_id"]),
+ result["client_secret"],
+ datetime.datetime.fromtimestamp(
+ result["client_id_issued_at"]),
+ datetime.datetime.fromtimestamp(
+ result["client_secret_expires_at"]),
+ json.loads(result["client_metadata"]),
+ the_user))# type: ignore[arg-type]
+
+ return Nothing
+
+def client_by_id_and_secret(conn: db.DbConnection, client_id: UUID,
+ client_secret: str) -> OAuth2Client:
+ """Retrieve a client by its ID and secret"""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT * FROM oauth2_clients WHERE client_id=?",
+ (str(client_id),))
+ row = cursor.fetchone()
+ if bool(row) and same_password(client_secret, row["client_secret"]):
+ return OAuth2Client(
+ client_id, client_secret,
+ datetime.datetime.fromtimestamp(row["client_id_issued_at"]),
+ datetime.datetime.fromtimestamp(
+ row["client_secret_expires_at"]),
+ json.loads(row["client_metadata"]),
+ user_by_id(conn, UUID(row["user_id"])))
+
+ raise NotFoundError("Could not find client with the given credentials.")
+
+def save_client(conn: db.DbConnection, the_client: OAuth2Client) -> OAuth2Client:
+ """Persist the client details into the database."""
+ with db.cursor(conn) as cursor:
+ query = (
+ "INSERT INTO oauth2_clients "
+ "(client_id, client_secret, client_id_issued_at, "
+ "client_secret_expires_at, client_metadata, user_id) "
+ "VALUES "
+ "(:client_id, :client_secret, :client_id_issued_at, "
+ ":client_secret_expires_at, :client_metadata, :user_id) "
+ "ON CONFLICT (client_id) DO UPDATE SET "
+ "client_secret=:client_secret, "
+ "client_id_issued_at=:client_id_issued_at, "
+ "client_secret_expires_at=:client_secret_expires_at, "
+ "client_metadata=:client_metadata, user_id=:user_id")
+ cursor.execute(
+ query,
+ {
+ "client_id": str(the_client.client_id),
+ "client_secret": the_client.client_secret,
+ "client_id_issued_at": (
+ the_client.client_id_issued_at.timestamp()),
+ "client_secret_expires_at": (
+ the_client.client_secret_expires_at.timestamp()),
+ "client_metadata": json.dumps(the_client.client_metadata),
+ "user_id": str(the_client.user.user_id)
+ })
+ return the_client
+
+def oauth2_clients(conn: db.DbConnection) -> tuple[OAuth2Client, ...]:
+ """Fetch a list of all OAuth2 clients."""
+ with db.cursor(conn) as cursor:
+ cursor.execute("SELECT * FROM oauth2_clients")
+ clients_rs = cursor.fetchall()
+ the_users = {
+ usr.user_id: usr for usr in users(
+ conn, tuple({UUID(result["user_id"]) for result in clients_rs}))
+ }
+ return tuple(OAuth2Client(UUID(result["client_id"]),
+ result["client_secret"],
+ datetime.datetime.fromtimestamp(
+ result["client_id_issued_at"]),
+ datetime.datetime.fromtimestamp(
+ result["client_secret_expires_at"]),
+ json.loads(result["client_metadata"]),
+ the_users[UUID(result["user_id"])])
+ for result in clients_rs)
+
+def delete_client(conn: db.DbConnection, the_client: OAuth2Client) -> OAuth2Client:
+ """Delete the given client from the database"""
+ with db.cursor(conn) as cursor:
+ params = (str(the_client.client_id),)
+ cursor.execute("DELETE FROM authorisation_code WHERE client_id=?",
+ params)
+ cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params)
+ cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params)
+ return the_client
diff --git a/gn3/auth/authorisation/oauth2/oauth2token.py b/gn3/auth/authorisation/oauth2/oauth2token.py
new file mode 100644
index 0000000..bb19039
--- /dev/null
+++ b/gn3/auth/authorisation/oauth2/oauth2token.py
@@ -0,0 +1,133 @@
+"""OAuth2 Token"""
+import uuid
+import datetime
+from typing import NamedTuple, Optional
+
+from pymonad.maybe import Just, Maybe, Nothing
+
+from gn3.auth import db
+
+from gn3.auth.authorisation.errors import NotFoundError
+from gn3.auth.authorisation.users import User, user_by_id
+
+from .oauth2client import client, OAuth2Client
+
+class OAuth2Token(NamedTuple):
+ """Implement Tokens for OAuth2."""
+ token_id: uuid.UUID
+ client: OAuth2Client
+ token_type: str
+ access_token: str
+ refresh_token: Optional[str]
+ scope: str
+ revoked: bool
+ issued_at: datetime.datetime
+ expires_in: int
+ user: User
+
+ @property
+ def expires_at(self) -> datetime.datetime:
+ """Return the time when the token expires."""
+ return self.issued_at + datetime.timedelta(seconds=self.expires_in)
+
+ def check_client(self, client: OAuth2Client) -> bool:# pylint: disable=[redefined-outer-name]
+ """Check whether the token is issued to given `client`."""
+ return client.client_id == self.client.client_id
+
+ def get_expires_in(self) -> int:
+ """Return the `expires_in` value for the token."""
+ return self.expires_in
+
+ def get_scope(self) -> str:
+ """Return the valid scope for the token."""
+ return self.scope
+
+ def is_expired(self) -> bool:
+ """Check whether the token is expired."""
+ return self.expires_at < datetime.datetime.now()
+
+ def is_revoked(self):
+ """Check whether the token has been revoked."""
+ return self.revoked
+
+def __token_from_resultset__(conn: db.DbConnection, rset) -> Maybe:
+ def __identity__(val):
+ return val
+ try:
+ the_user = user_by_id(conn, uuid.UUID(rset["user_id"]))
+ except NotFoundError as _nfe:
+ the_user = None
+ the_client = client(conn, uuid.UUID(rset["client_id"]), the_user)
+
+ if the_client.is_just() and bool(the_user):
+ return Just(OAuth2Token(token_id=uuid.UUID(rset["token_id"]),
+ client=the_client.maybe(None, __identity__),
+ token_type=rset["token_type"],
+ access_token=rset["access_token"],
+ refresh_token=rset["refresh_token"],
+ scope=rset["scope"],
+ revoked=(rset["revoked"] == 1),
+ issued_at=datetime.datetime.fromtimestamp(
+ rset["issued_at"]),
+ expires_in=rset["expires_in"],
+ user=the_user))# type: ignore[arg-type]
+
+ return Nothing
+
+def token_by_access_token(conn: db.DbConnection, token_str: str) -> Maybe:
+ """Retrieve token by its token string"""
+ with db.cursor(conn) as cursor:
+ cursor.execute("SELECT * FROM oauth2_tokens WHERE access_token=?",
+ (token_str,))
+ res = cursor.fetchone()
+ if res:
+ return __token_from_resultset__(conn, res)
+
+ return Nothing
+
+def token_by_refresh_token(conn: db.DbConnection, token_str: str) -> Maybe:
+ """Retrieve token by its token string"""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT * FROM oauth2_tokens WHERE refresh_token=?",
+ (token_str,))
+ res = cursor.fetchone()
+ if res:
+ return __token_from_resultset__(conn, res)
+
+ return Nothing
+
+def revoke_token(token: OAuth2Token) -> OAuth2Token:
+ """
+ Return a new token derived from `token` with the `revoked` field set to
+ `True`.
+ """
+ return OAuth2Token(
+ token_id=token.token_id, client=token.client,
+ token_type=token.token_type, access_token=token.access_token,
+ refresh_token=token.refresh_token, scope=token.scope, revoked=True,
+ issued_at=token.issued_at, expires_in=token.expires_in, user=token.user)
+
+def save_token(conn: db.DbConnection, token: OAuth2Token) -> None:
+ """Save/Update the token."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ ("INSERT INTO oauth2_tokens VALUES (:token_id, :client_id, "
+ ":token_type, :access_token, :refresh_token, :scope, :revoked, "
+ ":issued_at, :expires_in, :user_id) "
+ "ON CONFLICT (token_id) DO UPDATE SET "
+ "refresh_token=:refresh_token, revoked=:revoked, "
+ "expires_in=:expires_in "
+ "WHERE token_id=:token_id"),
+ {
+ "token_id": str(token.token_id),
+ "client_id": str(token.client.client_id),
+ "token_type": token.token_type,
+ "access_token": token.access_token,
+ "refresh_token": token.refresh_token,
+ "scope": token.scope,
+ "revoked": 1 if token.revoked else 0,
+ "issued_at": int(token.issued_at.timestamp()),
+ "expires_in": token.expires_in,
+ "user_id": str(token.user.user_id)
+ })
diff --git a/gn3/auth/authorisation/oauth2/resource_server.py b/gn3/auth/authorisation/oauth2/resource_server.py
new file mode 100644
index 0000000..e806dc5
--- /dev/null
+++ b/gn3/auth/authorisation/oauth2/resource_server.py
@@ -0,0 +1,19 @@
+"""Protect the resources endpoints"""
+
+from flask import current_app as app
+from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator
+from authlib.integrations.flask_oauth2 import ResourceProtector
+
+from gn3.auth import db
+from gn3.auth.authorisation.oauth2.oauth2token import token_by_access_token
+
+class BearerTokenValidator(_BearerTokenValidator):
+ """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`"""
+ def authenticate_token(self, token_string: str):
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ return token_by_access_token(conn, token_string).maybe(# type: ignore[misc]
+ None, lambda tok: tok)
+
+require_oauth = ResourceProtector()
+
+require_oauth.register_token_validator(BearerTokenValidator())
diff --git a/gn3/auth/authorisation/privileges.py b/gn3/auth/authorisation/privileges.py
index dbb4129..7907d76 100644
--- a/gn3/auth/authorisation/privileges.py
+++ b/gn3/auth/authorisation/privileges.py
@@ -2,7 +2,7 @@
from typing import Any, Iterable, NamedTuple
from gn3.auth import db
-from gn3.auth.authentication.users import User
+from gn3.auth.authorisation.users import User
class Privilege(NamedTuple):
"""Class representing a privilege: creates immutable objects."""
diff --git a/gn3/auth/authorisation/resources/checks.py b/gn3/auth/authorisation/resources/checks.py
index fafde76..1f5a0f9 100644
--- a/gn3/auth/authorisation/resources/checks.py
+++ b/gn3/auth/authorisation/resources/checks.py
@@ -4,7 +4,7 @@ from functools import reduce
from typing import Sequence
from gn3.auth import db
-from gn3.auth.authentication.users import User
+from gn3.auth.authorisation.users import User
def __organise_privileges_by_resource_id__(rows):
def __organise__(privs, row):
diff --git a/gn3/auth/authorisation/resources/models.py b/gn3/auth/authorisation/resources/models.py
index b301a93..cf7769e 100644
--- a/gn3/auth/authorisation/resources/models.py
+++ b/gn3/auth/authorisation/resources/models.py
@@ -7,7 +7,7 @@ from typing import Any, Dict, Sequence, Optional, NamedTuple
from gn3.auth import db
from gn3.auth.dictify import dictify
-from gn3.auth.authentication.users import User
+from gn3.auth.authorisation.users import User
from gn3.auth.db_utils import with_db_connection
from .checks import authorised_for
diff --git a/gn3/auth/authorisation/resources/views.py b/gn3/auth/authorisation/resources/views.py
index 3b2bbeb..bda67cd 100644
--- a/gn3/auth/authorisation/resources/views.py
+++ b/gn3/auth/authorisation/resources/views.py
@@ -7,6 +7,8 @@ from functools import reduce
from flask import request, jsonify, Response, Blueprint, current_app as app
from gn3.auth.db_utils import with_db_connection
+from gn3.auth.authorisation.oauth2.resource_server import require_oauth
+from gn3.auth.authorisation.users import User, user_by_id, user_by_email
from .checks import authorised_for
from .models import (
@@ -21,8 +23,6 @@ from ..groups.models import Group, GroupRole, group_role_by_id
from ... import db
from ...dictify import dictify
-from ...authentication.oauth2.resource_server import require_oauth
-from ...authentication.users import User, user_by_id, user_by_email
resources = Blueprint("resources", __name__)
diff --git a/gn3/auth/authorisation/roles/models.py b/gn3/auth/authorisation/roles/models.py
index 97e11af..890d33b 100644
--- a/gn3/auth/authorisation/roles/models.py
+++ b/gn3/auth/authorisation/roles/models.py
@@ -7,7 +7,7 @@ from pymonad.either import Left, Right, Either
from gn3.auth import db
from gn3.auth.dictify import dictify
-from gn3.auth.authentication.users import User
+from gn3.auth.authorisation.users import User
from gn3.auth.authorisation.errors import AuthorisationError
from ..checks import authorised_p
diff --git a/gn3/auth/authorisation/roles/views.py b/gn3/auth/authorisation/roles/views.py
index 3670aab..d00e596 100644
--- a/gn3/auth/authorisation/roles/views.py
+++ b/gn3/auth/authorisation/roles/views.py
@@ -5,11 +5,10 @@ from flask import jsonify, Response, Blueprint, current_app
from gn3.auth import db
from gn3.auth.dictify import dictify
+from gn3.auth.authorisation.oauth2.resource_server import require_oauth
from .models import user_role
-from ...authentication.oauth2.resource_server import require_oauth
-
roles = Blueprint("roles", __name__)
@roles.route("/view/<uuid:role_id>", methods=["GET"])
diff --git a/gn3/auth/authorisation/users/__init__.py b/gn3/auth/authorisation/users/__init__.py
index e69de29..5f0c89c 100644
--- a/gn3/auth/authorisation/users/__init__.py
+++ b/gn3/auth/authorisation/users/__init__.py
@@ -0,0 +1,12 @@
+"""Initialise the users' package."""
+from .base import (
+ User,
+ users,
+ save_user,
+ user_by_id,
+ # valid_login,
+ user_by_email,
+ hash_password, # only used in tests... maybe make gn-auth a GN3 dependency
+ same_password,
+ set_user_password
+)
diff --git a/gn3/auth/authorisation/users/admin/__init__.py b/gn3/auth/authorisation/users/admin/__init__.py
deleted file mode 100644
index 8aa0743..0000000
--- a/gn3/auth/authorisation/users/admin/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-"""The admin module"""
-from .views import admin
diff --git a/gn3/auth/authorisation/users/admin/ui.py b/gn3/auth/authorisation/users/admin/ui.py
deleted file mode 100644
index 242c7a6..0000000
--- a/gn3/auth/authorisation/users/admin/ui.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""UI utilities for the auth system."""
-from functools import wraps
-from flask import flash, url_for, redirect
-
-from gn3.auth.authentication.users import User
-from gn3.auth.db_utils import with_db_connection
-from gn3.auth.authorisation.roles.models import user_roles
-
-from gn3.session import logged_in, session_user, clear_session_info
-
-def is_admin(func):
- """Verify user is a system admin."""
- @wraps(func)
- @logged_in
- def __admin__(*args, **kwargs):
- admin_roles = [
- role for role in with_db_connection(
- lambda conn: user_roles(
- conn, User(**session_user())))
- if role.role_name == "system-administrator"]
- if len(admin_roles) > 0:
- return func(*args, **kwargs)
- flash("Expected a system administrator.", "alert-danger")
- flash("You have been logged out of the system.", "alert-info")
- clear_session_info()
- return redirect(url_for("oauth2.admin.login"))
- return __admin__
diff --git a/gn3/auth/authorisation/users/admin/views.py b/gn3/auth/authorisation/users/admin/views.py
deleted file mode 100644
index c9f1887..0000000
--- a/gn3/auth/authorisation/users/admin/views.py
+++ /dev/null
@@ -1,230 +0,0 @@
-"""UI for admin stuff"""
-import uuid
-import json
-import random
-import string
-from functools import partial
-from datetime import datetime, timezone, timedelta
-
-from email_validator import validate_email, EmailNotValidError
-from flask import (
- flash,
- request,
- url_for,
- redirect,
- Blueprint,
- current_app,
- render_template)
-
-
-from gn3 import session
-from gn3.auth import db
-from gn3.auth.db_utils import with_db_connection
-
-from gn3.auth.authentication.oauth2.models.oauth2client import (
- save_client,
- OAuth2Client,
- oauth2_clients,
- client as oauth2_client,
- delete_client as _delete_client)
-from gn3.auth.authentication.users import (
- User,
- user_by_id,
- valid_login,
- user_by_email,
- hash_password)
-
-from .ui import is_admin
-
-admin = Blueprint("admin", __name__)
-
-@admin.before_request
-def update_expires():
- """Update session expiration."""
- if session.session_info() and not session.update_expiry():
- flash("Session has expired. Logging out...", "alert-warning")
- session.clear_session_info()
- return redirect(url_for("oauth2.admin.login"))
- return None
-
-@admin.route("/dashboard", methods=["GET"])
-@is_admin
-def dashboard():
- """Admin dashboard."""
- return render_template("admin/dashboard.html")
-
-@admin.route("/login", methods=["GET", "POST"])
-def login():
- """Log in to GN3 directly without OAuth2 client."""
- if request.method == "GET":
- return render_template(
- "admin/login.html",
- next_uri=request.args.get("next", "oauth2.admin.dashboard"))
-
- form = request.form
- next_uri = form.get("next_uri", "oauth2.admin.dashboard")
- error_message = "Invalid email or password provided."
- login_page = redirect(url_for("oauth2.admin.login", next=next_uri))
- try:
- email = validate_email(form.get("email", "").strip(),
- check_deliverability=False)
- password = form.get("password")
- with db.connection(current_app.config["AUTH_DB"]) as conn:
- user = user_by_email(conn, email["email"])
- if valid_login(conn, user, password):
- session.update_session_info(
- user=user._asdict(),
- expires=(
- datetime.now(tz=timezone.utc) + timedelta(minutes=10)))
- return redirect(url_for(next_uri))
- flash(error_message, "alert-danger")
- return login_page
- except EmailNotValidError as _enve:
- flash(error_message, "alert-danger")
- return login_page
-
-@admin.route("/logout", methods=["GET"])
-def logout():
- """Log out the admin."""
- if not session.session_info():
- flash("Not logged in.", "alert-info")
- return redirect(url_for("oauth2.admin.login"))
- session.clear_session_info()
- flash("Logged out", "alert-success")
- return redirect(url_for("oauth2.admin.login"))
-
-def random_string(length: int = 64) -> str:
- """Generate a random string."""
- return "".join(
- random.choice(string.ascii_letters + string.digits + string.punctuation)
- for _idx in range(0, length))
-
-def __response_types__(grant_types: tuple[str, ...]) -> tuple[str, ...]:
- """Compute response types from grant types."""
- resps = {
- "password": ("token",),
- "authorization_code": ("token", "code"),
- "refresh_token": ("token",)
- }
- return tuple(set(
- resp_typ for types_list
- in (types for grant, types in resps.items() if grant in grant_types)
- for resp_typ in types_list))
-
-@admin.route("/register-client", methods=["GET", "POST"])
-@is_admin
-def register_client():
- """Register an OAuth2 client."""
- def __list_users__(conn):
- with db.cursor(conn) as cursor:
- cursor.execute("SELECT * FROM users")
- return tuple(
- User(uuid.UUID(row["user_id"]), row["email"], row["name"])
- for row in cursor.fetchall())
- if request.method == "GET":
- return render_template(
- "admin/register-client.html",
- scope=current_app.config["OAUTH2_SCOPE"],
- users=with_db_connection(__list_users__),
- current_user=session.session_user())
-
- form = request.form
- raw_client_secret = random_string()
- default_redirect_uri = form["redirect_uri"]
- grant_types = form.getlist("grants[]")
- client = OAuth2Client(
- client_id = uuid.uuid4(),
- client_secret = hash_password(raw_client_secret),
- client_id_issued_at = datetime.now(tz=timezone.utc),
- client_secret_expires_at = datetime.fromtimestamp(0),
- client_metadata = {
- "client_name": "GN2 Dev Server",
- "token_endpoint_auth_method": [
- "client_secret_post", "client_secret_basic"],
- "client_type": "confidential",
- "grant_types": ["password", "authorization_code", "refresh_token"],
- "default_redirect_uri": default_redirect_uri,
- "redirect_uris": [default_redirect_uri] + form.get("other_redirect_uri", "").split(),
- "response_type": __response_types__(tuple(grant_types)),
- "scope": form.getlist("scope[]")
- },
- user = with_db_connection(partial(
- user_by_id, user_id=uuid.UUID(form["user"])))
- )
- client = with_db_connection(partial(save_client, the_client=client))
- return render_template(
- "admin/registered-client.html",
- client=client,
- client_secret = raw_client_secret)
-
-def __parse_client__(sqlite3_row) -> dict:
- """Parse the client details into python datatypes."""
- return {
- **dict(sqlite3_row),
- "client_metadata": json.loads(sqlite3_row["client_metadata"])
- }
-
-@admin.route("/list-client", methods=["GET"])
-@is_admin
-def list_clients():
- """List all registered OAuth2 clients."""
- return render_template(
- "admin/list-oauth2-clients.html",
- clients=with_db_connection(oauth2_clients))
-
-@admin.route("/view-client/<uuid:client_id>", methods=["GET"])
-@is_admin
-def view_client(client_id: uuid.UUID):
- """View details of OAuth2 client with given `client_id`."""
- return render_template(
- "admin/view-oauth2-client.html",
- client=with_db_connection(partial(oauth2_client, client_id=client_id)),
- scope=current_app.config["OAUTH2_SCOPE"])
-
-@admin.route("/edit-client", methods=["POST"])
-@is_admin
-def edit_client():
- """Edit the details of the given client."""
- form = request.form
- the_client = with_db_connection(partial(
- oauth2_client, client_id=uuid.UUID(form["client_id"])))
- if the_client.is_nothing():
- flash("No such client.", "alert-danger")
- return redirect(url_for("oauth2.admin.list_clients"))
- the_client = the_client.value
- client_metadata = {
- **the_client.client_metadata,
- "default_redirect_uri": form["default_redirect_uri"],
- "redirect_uris": list(set(
- [form["default_redirect_uri"]] +
- form["other_redirect_uris"].split("\r\n"))),
- "grants": form.getlist("grants[]"),
- "scope": form.getlist("scope[]")
- }
- with_db_connection(partial(save_client, the_client=OAuth2Client(
- the_client.client_id,
- the_client.client_secret,
- the_client.client_id_issued_at,
- the_client.client_secret_expires_at,
- client_metadata,
- the_client.user)))
- flash("Client updated.", "alert-success")
- return redirect(url_for("oauth2.admin.view_client",
- client_id=the_client.client_id))
-
-@admin.route("/delete-client", methods=["POST"])
-@is_admin
-def delete_client():
- """Delete the details of the client."""
- form = request.form
- the_client = with_db_connection(partial(
- oauth2_client, client_id=uuid.UUID(form["client_id"])))
- if the_client.is_nothing():
- flash("No such client.", "alert-danger")
- return redirect(url_for("oauth2.admin.list_clients"))
- the_client = the_client.value
- with_db_connection(partial(_delete_client, client=the_client))
- flash((f"Client '{the_client.client_metadata.client_name}' was deleted "
- "successfully."),
- "alert-success")
- return redirect(url_for("oauth2.admin.list_clients"))
diff --git a/gn3/auth/authorisation/users/base.py b/gn3/auth/authorisation/users/base.py
new file mode 100644
index 0000000..0e72ed2
--- /dev/null
+++ b/gn3/auth/authorisation/users/base.py
@@ -0,0 +1,128 @@
+"""User-specific code and data structures."""
+from uuid import UUID, uuid4
+from typing import Any, Tuple, NamedTuple
+
+from argon2 import PasswordHasher
+from argon2.exceptions import VerifyMismatchError
+
+from gn3.auth import db
+from gn3.auth.authorisation.errors import NotFoundError
+
+class User(NamedTuple):
+ """Class representing a user."""
+ user_id: UUID
+ email: str
+ name: str
+
+ def get_user_id(self):
+ """Return the user's UUID. Mostly for use with Authlib."""
+ return self.user_id
+
+ def dictify(self) -> dict[str, Any]:
+ """Return a dict representation of `User` objects."""
+ return {"user_id": self.user_id, "email": self.email, "name": self.name}
+
+DUMMY_USER = User(user_id=UUID("a391cf60-e8b7-4294-bd22-ddbbda4b3530"),
+ email="gn3@dummy.user",
+ name="Dummy user to use as placeholder")
+
+def user_by_email(conn: db.DbConnection, email: str) -> User:
+ """Retrieve user from database by their email address"""
+ with db.cursor(conn) as cursor:
+ cursor.execute("SELECT * FROM users WHERE email=?", (email,))
+ row = cursor.fetchone()
+
+ if row:
+ return User(UUID(row["user_id"]), row["email"], row["name"])
+
+ raise NotFoundError(f"Could not find user with email {email}")
+
+def user_by_id(conn: db.DbConnection, user_id: UUID) -> User:
+ """Retrieve user from database by their user id"""
+ with db.cursor(conn) as cursor:
+ cursor.execute("SELECT * FROM users WHERE user_id=?", (str(user_id),))
+ row = cursor.fetchone()
+
+ if row:
+ return User(UUID(row["user_id"]), row["email"], row["name"])
+
+ raise NotFoundError(f"Could not find user with ID {user_id}")
+
+def same_password(password: str, hashed: str) -> bool:
+ """Check that `raw_password` is hashed to `hash`"""
+ try:
+ return hasher().verify(hashed, password)
+ except VerifyMismatchError as _vme:
+ return False
+
+def valid_login(conn: db.DbConnection, user: User, password: str) -> bool:
+ """Check the validity of the provided credentials for login."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ ("SELECT * FROM users LEFT JOIN user_credentials "
+ "ON users.user_id=user_credentials.user_id "
+ "WHERE users.user_id=?"),
+ (str(user.user_id),))
+ row = cursor.fetchone()
+
+ if row is None:
+ return False
+
+ return same_password(password, row["password"])
+
+def save_user(cursor: db.DbCursor, email: str, name: str) -> User:
+ """
+ Create and persist a user.
+
+ The user creation could be done during a transaction, therefore the function
+ takes a cursor object rather than a connection.
+
+ The newly created and persisted user is then returned.
+ """
+ user_id = uuid4()
+ cursor.execute("INSERT INTO users VALUES (?, ?, ?)",
+ (str(user_id), email, name))
+ return User(user_id, email, name)
+
+def hasher():
+ """Retrieve PasswordHasher object"""
+ # TODO: Maybe tune the parameters here...
+ # Tuneable Parameters:
+ # - time_cost (default: 2)
+ # - memory_cost (default: 102400)
+ # - parallelism (default: 8)
+ # - hash_len (default: 16)
+ # - salt_len (default: 16)
+ # - encoding (default: 'utf-8')
+ # - type (default: <Type.ID: 2>)
+ return PasswordHasher()
+
+def hash_password(password):
+ """Hash the password."""
+ return hasher().hash(password)
+
+def set_user_password(
+ cursor: db.DbCursor, user: User, password: str) -> Tuple[User, bytes]:
+ """Set the given user's password in the database."""
+ hashed_password = hash_password(password)
+ cursor.execute(
+ ("INSERT INTO user_credentials VALUES (:user_id, :hash) "
+ "ON CONFLICT (user_id) DO UPDATE SET password=:hash"),
+ {"user_id": str(user.user_id), "hash": hashed_password})
+ return user, hashed_password
+
+def users(conn: db.DbConnection,
+ ids: tuple[UUID, ...] = tuple()) -> tuple[User, ...]:
+ """
+ Fetch all users with the given `ids`. If `ids` is empty, return ALL users.
+ """
+ params = ", ".join(["?"] * len(ids))
+ with db.cursor(conn) as cursor:
+ query = "SELECT * FROM users" + (
+ f" WHERE user_id IN ({params})"
+ if len(ids) > 0 else "")
+ print(query)
+ cursor.execute(query, tuple(str(the_id) for the_id in ids))
+ return tuple(User(UUID(row["user_id"]), row["email"], row["name"])
+ for row in cursor.fetchall())
+ return tuple()
diff --git a/gn3/auth/authorisation/users/collections/views.py b/gn3/auth/authorisation/users/collections/views.py
index 1fa25a3..775e8bc 100644
--- a/gn3/auth/authorisation/users/collections/views.py
+++ b/gn3/auth/authorisation/users/collections/views.py
@@ -9,8 +9,8 @@ from gn3.auth.db_utils import with_db_connection
from gn3.auth.authorisation.checks import require_json
from gn3.auth.authorisation.errors import NotFoundError
-from gn3.auth.authentication.users import User, user_by_id
-from gn3.auth.authentication.oauth2.resource_server import require_oauth
+from gn3.auth.authorisation.users import User, user_by_id
+from gn3.auth.authorisation.oauth2.resource_server import require_oauth
from .models import (
add_traits,
diff --git a/gn3/auth/authorisation/users/masquerade/__init__.py b/gn3/auth/authorisation/users/masquerade/__init__.py
deleted file mode 100644
index 69d64f0..0000000
--- a/gn3/auth/authorisation/users/masquerade/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Package to deal with masquerading."""
diff --git a/gn3/auth/authorisation/users/masquerade/models.py b/gn3/auth/authorisation/users/masquerade/models.py
deleted file mode 100644
index 9f24b6b..0000000
--- a/gn3/auth/authorisation/users/masquerade/models.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""Functions for handling masquerade."""
-from uuid import uuid4
-from functools import wraps
-from datetime import datetime
-
-from flask import current_app as app
-
-from gn3.auth import db
-
-from gn3.auth.authorisation.errors import ForbiddenAccess
-from gn3.auth.authorisation.roles.models import user_roles
-
-from gn3.auth.authentication.users import User
-from gn3.auth.authentication.oauth2.models.oauth2token import (
- OAuth2Token, save_token)
-
-__FIVE_HOURS__ = (60 * 60 * 5)
-
-def can_masquerade(func):
- """Security decorator."""
- @wraps(func)
- def __checker__(*args, **kwargs):
- if len(args) == 3:
- conn, token, _masq_user = args
- elif len(args) == 2:
- conn, token = args
- elif len(args) == 1:
- conn = args[0]
- token = kwargs["original_token"]
- else:
- conn = kwargs["conn"]
- token = kwargs["original_token"]
-
- masq_privs = [priv for role in user_roles(conn, token.user)
- for priv in role.privileges
- if priv.privilege_id == "system:user:masquerade"]
- if len(masq_privs) == 0:
- raise ForbiddenAccess(
- "You do not have the ability to masquerade as another user.")
- return func(*args, **kwargs)
- return __checker__
-
-@can_masquerade
-def masquerade_as(
- conn: db.DbConnection,
- original_token: OAuth2Token,
- masqueradee: User) -> OAuth2Token:
- """Get a token that enables `masquerader` to act as `masqueradee`."""
- token_details = app.config["OAUTH2_SERVER"].generate_token(
- client=original_token.client,
- grant_type="authorization_code",
- user=masqueradee,
- expires_in=__FIVE_HOURS__,
- include_refresh_token=True)
- new_token = OAuth2Token(
- token_id=uuid4(),
- client=original_token.client,
- token_type=token_details["token_type"],
- access_token=token_details["access_token"],
- refresh_token=token_details.get("refresh_token"),
- scope=original_token.scope,
- revoked=False,
- issued_at=datetime.now(),
- expires_in=token_details["expires_in"],
- user=masqueradee)
- save_token(conn, new_token)
- return new_token
diff --git a/gn3/auth/authorisation/users/masquerade/views.py b/gn3/auth/authorisation/users/masquerade/views.py
deleted file mode 100644
index 43286a1..0000000
--- a/gn3/auth/authorisation/users/masquerade/views.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""Endpoints for user masquerade"""
-from uuid import UUID
-from functools import partial
-
-from flask import request, jsonify, Response, Blueprint
-
-from gn3.auth.db_utils import with_db_connection
-from gn3.auth.authorisation.errors import InvalidData
-from gn3.auth.authorisation.checks import require_json
-
-from gn3.auth.authentication.users import user_by_id
-from gn3.auth.authentication.oauth2.resource_server import require_oauth
-
-from .models import masquerade_as
-
-masq = Blueprint("masquerade", __name__)
-
-@masq.route("/", methods=["POST"])
-@require_oauth("profile user masquerade")
-@require_json
-def masquerade() -> Response:
- """Masquerade as a particular user."""
- with require_oauth.acquire("profile user masquerade") as token:
- masqueradee_id = UUID(request.json["masquerade_as"])#type: ignore[index]
- if masqueradee_id == token.user.user_id:
- raise InvalidData("You are not allowed to masquerade as yourself.")
-
- masq_user = with_db_connection(partial(
- user_by_id, user_id=masqueradee_id))
- def __masq__(conn):
- new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user)
- return new_token
- def __dump_token__(tok):
- return {
- key: value for key, value in (tok._asdict().items())
- if key in ("access_token", "refresh_token", "expires_in",
- "token_type")
- }
- return jsonify({
- "original": {
- "user": token.user._asdict(),
- "token": __dump_token__(token)
- },
- "masquerade_as": {
- "user": masq_user._asdict(),
- "token": __dump_token__(with_db_connection(__masq__))
- }
- })
diff --git a/gn3/auth/authorisation/users/models.py b/gn3/auth/authorisation/users/models.py
index 89c1d22..0157154 100644
--- a/gn3/auth/authorisation/users/models.py
+++ b/gn3/auth/authorisation/users/models.py
@@ -7,7 +7,7 @@ from gn3.auth.authorisation.roles.models import Role
from gn3.auth.authorisation.checks import authorised_p
from gn3.auth.authorisation.privileges import Privilege
-from gn3.auth.authentication.users import User
+from .base import User
@authorised_p(
("system:user:list",),
diff --git a/gn3/auth/authorisation/users/views.py b/gn3/auth/authorisation/users/views.py
index 826e222..f75b51e 100644
--- a/gn3/auth/authorisation/users/views.py
+++ b/gn3/auth/authorisation/users/views.py
@@ -10,9 +10,11 @@ from flask import request, jsonify, Response, Blueprint, current_app
from gn3.auth import db
from gn3.auth.dictify import dictify
from gn3.auth.db_utils import with_db_connection
+from gn3.auth.authorisation.oauth2.resource_server import require_oauth
+from gn3.auth.authorisation.users import User, save_user, set_user_password
+from gn3.auth.authorisation.oauth2.oauth2token import token_by_access_token
from .models import list_users
-from .masquerade.views import masq
from .collections.views import collections
from ..groups.models import user_group as _user_group
@@ -21,12 +23,7 @@ from ..roles.models import assign_default_roles, user_roles as _user_roles
from ..errors import (
NotFoundError, UsernameError, PasswordError, UserRegistrationError)
-from ...authentication.oauth2.resource_server import require_oauth
-from ...authentication.users import User, save_user, set_user_password
-from ...authentication.oauth2.models.oauth2token import token_by_access_token
-
users = Blueprint("users", __name__)
-users.register_blueprint(masq, url_prefix="/masquerade")
users.register_blueprint(collections, url_prefix="/collections")
@users.route("/", methods=["GET"])