about summary refs log tree commit diff
path: root/gn3/auth/authorisation
diff options
context:
space:
mode:
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"])