From a5860185ad489c347b1a72ade0a8ba8b5423ab43 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 18 Mar 2026 11:43:42 -0500 Subject: [PATCH 01/39] feat: allow authentication via JWT Bearer Tokens Fixes #5163 --- .../backend/accounts/auth_token_utils.py | 72 ++++++++++ specifyweb/backend/accounts/middleware.py | 43 ++++++ specifyweb/backend/accounts/urls.py | 3 + specifyweb/backend/accounts/views.py | 124 +++++++++++++++++- specifyweb/backend/context/middleware.py | 12 +- specifyweb/backend/redis_cache/store.py | 6 +- specifyweb/backend/redis_cache/utils.py | 6 +- specifyweb/settings/__init__.py | 1 + 8 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 specifyweb/backend/accounts/auth_token_utils.py create mode 100644 specifyweb/backend/accounts/middleware.py diff --git a/specifyweb/backend/accounts/auth_token_utils.py b/specifyweb/backend/accounts/auth_token_utils.py new file mode 100644 index 00000000000..20ebef50276 --- /dev/null +++ b/specifyweb/backend/accounts/auth_token_utils.py @@ -0,0 +1,72 @@ +import uuid + +import jwt + +from datetime import datetime, timezone, timedelta +from typing import Literal + +from django.conf import settings + +from specifyweb.backend.redis_cache.store import set_string, key_exists + +DEFAULT_AUTH_LIFESPAN_SECONDS = 1800 + +# See https://pyjwt.readthedocs.io/en/latest/api.html#jwt.decode +AUTH_JWT_DECODE_OPTIONS = { + "require": ["iat", "exp", "jti"], + "verify_signature": True, + "verify_iat": True, + "verify_exp": True +} + +AUTH_TOKEN_ALGORITHMS = ["HS256"] + +def generate_auth_token(user, collection_id: int, expires_in: int = DEFAULT_AUTH_LIFESPAN_SECONDS): + jti = str(uuid.uuid4()) + + jwt_payload = { + "sub": user.id, + "username": user.name, + "collection": collection_id, + "jti": jti, + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(seconds=expires_in) + } + token = jwt.encode(jwt_payload, settings.SECRET_KEY, algorithm=AUTH_TOKEN_ALGORITHMS[0]) + return token + + +def revoke_auth_token(token: dict): + """ + Accepts and revokes a decoded JWT Auth Token. + Specifically, stores the token in a "blacklist" in Redis for the remaining + time of the token. + The JWT Auth Middleware checks to see if the token is blacklisted during + authorization + """ + required_claims = ("jti", "exp") + if not all(k in token for k in required_claims): + raise ValueError(f"Token missing required claims: {required_claims}") + jti = token["jti"] + expires_at = token["exp"] + current_time = int(datetime.now(timezone.utc).timestamp()) + blacklist_ttl = expires_at - current_time + set_string(f"revoked:{jti}", "true", time_to_live=blacklist_ttl) + +def get_token_from_request(request) -> Literal[False] | dict: + auth_header = request.headers.get("Authorization") + if auth_header is None or not auth_header.startswith("Bearer "): + return False + + encoded_token = auth_header.split(" ")[1] + + try: + token = jwt.decode(encoded_token, settings.SECRET_KEY, options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) + except jwt.exceptions.InvalidTokenError: + return False + return token + + +def token_is_revoked(token: dict): + token_identifier = token["jti"] + return key_exists(f"revoked:{token_identifier}") diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py new file mode 100644 index 00000000000..1980aa06492 --- /dev/null +++ b/specifyweb/backend/accounts/middleware.py @@ -0,0 +1,43 @@ +import jwt + +from django.contrib.auth import login, authenticate +from django.utils.functional import SimpleLazyObject + +from specifyweb.specify.models import Collection, Specifyuser, Agent +from specifyweb.specify.api.filter_by_col import filter_by_collection +from specifyweb.backend.accounts.auth_token_utils import get_token_from_request, token_is_revoked + +def get_agent(request): + try: + return filter_by_collection(Agent.objects, request.specify_collection) \ + .select_related('specifyuser') \ + .get(specifyuser=request.specify_user) + except Agent.DoesNotExist: + return None + +class JWTAuthMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + token = get_token_from_request(request) + if token == False or token_is_revoked(token): + return self.get_response(request) + user_id = token["sub"] + collection_id = token["collection"] + + request.specify_collection = SimpleLazyObject(lambda: Collection.objects.get(id=collection_id)) + lazy_user = SimpleLazyObject(lambda: Specifyuser.objects.get(id=user_id)) + request.specify_user = lazy_user + request.user = lazy_user + request.specify_user_agent = SimpleLazyObject(lambda: get_agent(request)) + + # We can disable CSRF checks with users authenticated via JWT. + # This is ONLY because the end user must explicitly pass the auth token + # as a header, and is not stored within the session, cookies, etc. + # Essentially, with CSRF protection disabled for users authenticated + # via token, we have to be careful not to store any auth information in + # a stateful way within the session + # e.g., avoid calling django.contrib.auth.login + request._dont_enforce_csrf_checks = True + return self.get_response(request) diff --git a/specifyweb/backend/accounts/urls.py b/specifyweb/backend/accounts/urls.py index c088c507a26..3555b1028ca 100644 --- a/specifyweb/backend/accounts/urls.py +++ b/specifyweb/backend/accounts/urls.py @@ -13,6 +13,9 @@ # OpenId Connect callback endpoint: path('oic_callback/', views.oic_callback), + path('token/', views.acquire_auth_token), + path('token/revoke/', views.revoke_auth_token), + path( 'logout/', skip_collection_access_check(auth_views.LogoutView.as_view(next_page='/accounts/login/')) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 22f1e477401..0468fe8ec8d 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -6,11 +6,10 @@ import logging import requests import time -from urllib.parse import unquote_plus from django import forms from django import http from django.conf import settings -from django.contrib.auth import login +from django.contrib.auth import login, authenticate from django.contrib.auth.models import AbstractBaseUser from django.db import connection from django.db.models import Max @@ -19,6 +18,7 @@ from django.utils import crypto from django.utils.http import url_has_allowed_host_and_scheme, urlencode from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_exempt from typing import cast from specifyweb.backend.accounts.account_utils import check_collection_access_against_agents, is_provider_info @@ -32,10 +32,11 @@ from specifyweb.specify import models as spmodels from specifyweb.specify.views import login_maybe_required, openapi from .models import Spuserexternalid -from specifyweb.specify.models import Specifyuser +from specifyweb.specify.models import Specifyuser, Collection from django.views.decorators.http import require_POST from specifyweb.backend.permissions.permissions import check_permission_targets from specifyweb.specify.auth.support_login import b64_url_to_bytes +from specifyweb.backend.accounts.auth_token_utils import DEFAULT_AUTH_LIFESPAN_SECONDS, generate_auth_token, revoke_auth_token as revoke_token, AUTH_JWT_DECODE_OPTIONS, AUTH_TOKEN_ALGORITHMS from django.db import transaction, connection logger = logging.getLogger(__name__) @@ -354,7 +355,6 @@ def support_login(request: http.HttpRequest) -> http.HttpResponse: if not settings.ALLOW_SUPPORT_LOGIN: return http.HttpResponseForbidden() - from django.contrib.auth import login, authenticate token = request.GET["token"] key = b64_url_to_bytes(request.GET["key"]) @@ -580,3 +580,119 @@ def set_admin_status(request, userid): else: user.clear_admin() return http.HttpResponse('false', content_type='text/plain') + +@openapi(schema={ + 'post': { + "requestBody": { + "required": True, + "description": "Obtain an auhtorization token that can be used with the API", + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "collectionid": { + "type": "integer" + }, + "expires": { + "type": "integer", + "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS / 60} minutes if not specified", + "default": DEFAULT_AUTH_LIFESPAN_SECONDS + } + }, + "required": ['username', 'password', 'collectionid'], + 'additionalProperties': False + } + } + } + }, + "responses": { + "201": {"description": "The auth token was successfully generated", }, + "400": {"description": "One of the required keys was not supplied, or one or more keys were supplied incorrectly"}, + "403": {"description": "The provided credentials were incorrect"}, + "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} + } + }, +}) +@require_POST +@csrf_exempt +def acquire_auth_token(request): + username = request.POST.get("username") + password = request.POST.get("password") + collection_id = request.POST.get("collectionid") + expires_in = request.POST.get("expires", DEFAULT_AUTH_LIFESPAN_SECONDS) + + if None in (username, password, collection_id): + return http.HttpResponseBadRequest(f"username, password, and collection are required") + + if not isinstance(expires_in, int) or expires_in <= 0: + return http.HttpResponseBadRequest(f"Invalid expiry time for token") + + try: + collection = Collection.objects.get(id=collection_id) + except Collection.DoesNotExist: + return http.HttpResponseBadRequest(f'collection {collection_id} does not exist') + + user = authenticate(username=username, password=password) + + if user is None: + return http.HttpResponseForbidden() + + token = generate_auth_token(user, collection.id, expires_in=expires_in) + + # TODO: lower default expiry time and also issue refresh tokens that can be + # used to re-issue auth tokens + # Issue refresh tokens as HTTP-only cookies? Need to keep CSRF protection + # and replay attacks in mind. Maybe just store them in Redis... + response = http.JsonResponse({"auth_token": token}) + return response + +@openapi(schema={ + 'post': { + "requestBody": { + "required": True, + "description": "Revoke a previously granted authorization token", + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "The JWT Auth token to revoke" + }, + }, + "required": ['token'], + 'additionalProperties': False + } + } + } + }, + "responses": { + "204": {"description": "The auth token was revoked"}, + "400": {"description": "The provided token is already invalid or could not be validated"}, + "403": {"description": "The user was not logged-in when making a request to the endpoint"}, + "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} + } + }, +}) +@require_POST +@login_maybe_required +def revoke_auth_token(request): + encoded_token = request.POST.get("token") + + try: + token = jwt.decode(encoded_token, settings.SECRET_KEY, options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) + except jwt.exceptions.InvalidTokenError: + return http.HttpResponseBadRequest() + + revoke_token(token) + + return http.HttpResponse('', status=204) + diff --git a/specifyweb/backend/context/middleware.py b/specifyweb/backend/context/middleware.py index 51a8037c2e4..d9fb2c81681 100644 --- a/specifyweb/backend/context/middleware.py +++ b/specifyweb/backend/context/middleware.py @@ -54,9 +54,15 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - request.specify_collection = SimpleLazyObject(lambda: get_cached('_cached_collection', get_collection, request)) - request.specify_user_agent = SimpleLazyObject(lambda: get_cached('_cached_agent', get_agent, request)) - request.specify_user = SimpleLazyObject(lambda: get_cached('_cached_specify_user', get_user, request)) + # These can be set by middleware "higher" in the chain/before this one, + # particularly when using a different authentiation scheme, such as via + # JWT Auth token + if not hasattr(request, "specify_collection"): + request.specify_collection = SimpleLazyObject(lambda: get_cached('_cached_collection', get_collection, request)) + if not hasattr(request, "specify_user_agent"): + request.specify_user_agent = SimpleLazyObject(lambda: get_cached('_cached_agent', get_agent, request)) + if not hasattr(request, "specify_user"): + request.specify_user = SimpleLazyObject(lambda: get_cached('_cached_specify_user', get_user, request)) return self.get_response(request) diff --git a/specifyweb/backend/redis_cache/store.py b/specifyweb/backend/redis_cache/store.py index 102121839b0..27064c036e6 100644 --- a/specifyweb/backend/redis_cache/store.py +++ b/specifyweb/backend/redis_cache/store.py @@ -1,4 +1,4 @@ -from .utils import _set_string, _get_string, _delete_key, _add_to_set, _remove_from_set, _set_elements, _redis_type, format_key +from .utils import _set_string, _get_string, _delete_key, _add_to_set, _remove_from_set, _set_elements, _redis_type, format_key, _key_exists def set_string(key: str | bytes, value: str, time_to_live=None, override_existing=True): @@ -35,3 +35,7 @@ def delete_key(key: str | bytes): def redis_type(key: str | bytes): return _redis_type(format_key(key)) + + +def key_exists(key: str) -> bool: + return _key_exists(key) diff --git a/specifyweb/backend/redis_cache/utils.py b/specifyweb/backend/redis_cache/utils.py index 5682b559358..36cefe67e9a 100644 --- a/specifyweb/backend/redis_cache/utils.py +++ b/specifyweb/backend/redis_cache/utils.py @@ -76,4 +76,8 @@ def _set_elements(key: str) -> set: def _redis_type(key: str) -> Redis_Type: host = redis_connection(decode_responses=True) - return host.type(key) \ No newline at end of file + return host.type(key) + +def _key_exists(key: str) -> bool: + host = redis_connection(decode_responses=True) + return host.exists(key) \ No newline at end of file diff --git a/specifyweb/settings/__init__.py b/specifyweb/settings/__init__.py index 990c5ed8ce0..9f0562bada2 100644 --- a/specifyweb/settings/__init__.py +++ b/specifyweb/settings/__init__.py @@ -231,6 +231,7 @@ def get_sa_db_url(db_name): 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'specifyweb.backend.accounts.middleware.JWTAuthMiddleware', 'specifyweb.backend.context.middleware.ContextMiddleware', 'specifyweb.backend.permissions.middleware.PermissionsMiddleware', 'specifyweb.middleware.general.GeneralMiddleware', From c0dae4b726e29b2191a7186e3867aff3055b3a32 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 18 Mar 2026 11:46:25 -0500 Subject: [PATCH 02/39] chore: reformat redis utils --- specifyweb/backend/redis_cache/utils.py | 31 ++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/specifyweb/backend/redis_cache/utils.py b/specifyweb/backend/redis_cache/utils.py index 36cefe67e9a..9c51b2afd3b 100644 --- a/specifyweb/backend/redis_cache/utils.py +++ b/specifyweb/backend/redis_cache/utils.py @@ -9,15 +9,19 @@ def redis_connection(decode_responses=True): redis_port = getattr(settings, "REDIS_PORT", None) redis_db_index = getattr(settings, "REDIS_DB_INDEX", 0) if None in (redis_host, redis_port, redis_db_index): - raise ValueError("Redis is not correctly configured", redis_host, redis_port) + raise ValueError("Redis is not correctly configured", + redis_host, redis_port) return Redis(host=redis_host, port=redis_port, db=redis_db_index, decode_responses=decode_responses) + @overload def format_key(key: str) -> str: ... + @overload def format_key(key: bytes) -> bytes: ... + def format_key(key: str | bytes) -> str | bytes: """Formats key to avoid collisions when specify instances are sharing a cache. Expected format: specify:{database}:app:name""" @@ -26,10 +30,12 @@ def format_key(key: str | bytes) -> str | bytes: db_name = getattr(settings, "DATABASE_NAME") return key.format(database=db_name) + def _delete_key(key: str): host = redis_connection() host.delete(key) + def _set_string(key: str, value: str, time_to_live=None, override_existing=True, decode_responses=True): host = redis_connection(decode_responses=decode_responses) # See https://redis.readthedocs.io/en/stable/commands.html#redis.commands.core.CoreCommands.set @@ -41,43 +47,52 @@ def _set_string(key: str, value: str, time_to_live=None, override_existing=True, @overload -def _get_string(key: str, delete_key: bool, decode_responses: True) -> str | None: ... +def _get_string(key: str, delete_key: bool, + decode_responses: True) -> str | None: ... @overload -def _get_string(key: str, delete_key: bool, decode_responses: False) -> bytes | None: ... +def _get_string(key: str, delete_key: bool, + decode_responses: False) -> bytes | None: ... -def _get_string(key: str, delete_key: bool=False, decode_responses=True) -> str | bytes | None: +def _get_string(key: str, delete_key: bool = False, decode_responses=True) -> str | bytes | None: host = redis_connection(decode_responses=decode_responses) - if delete_key: + if delete_key: return host.getdel(key) - + return host.get(key) + def _add_to_set(key: str, *elements: str): if len(elements) <= 0: return 0 host = redis_connection(decode_responses=True) return host.sadd(key, *elements) + def _remove_from_set(key: str, *elements: str) -> int: if len(elements) <= 0: return 0 host = redis_connection(decode_responses=True) return host.srem(key, *elements) + def _set_elements(key: str) -> set: host = redis_connection(decode_responses=True) return host.smembers(key) + # https://redis.io/docs/latest/commands/type/ -Redis_Type = Literal["none", "string", "list", "set", "hash", "stream", "vectorset"] +Redis_Type = Literal["none", "string", "list", + "set", "hash", "stream", "vectorset"] + def _redis_type(key: str) -> Redis_Type: host = redis_connection(decode_responses=True) return host.type(key) + def _key_exists(key: str) -> bool: host = redis_connection(decode_responses=True) - return host.exists(key) \ No newline at end of file + return host.exists(key) From e3e974d9b8983492e9870a7263f306b5867b6dee Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 18 Mar 2026 16:51:45 +0000 Subject: [PATCH 03/39] Lint code with ESLint and Prettier Triggered by 35611736023b2d35ca75a6b8a1deaf3b8834dbe2 on branch refs/heads/issue-5163 --- specifyweb/frontend/js_src/lib/hooks/useValidation.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/hooks/useValidation.tsx b/specifyweb/frontend/js_src/lib/hooks/useValidation.tsx index 511c3f97fa3..0bf10bd8848 100644 --- a/specifyweb/frontend/js_src/lib/hooks/useValidation.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/useValidation.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { InFormEditorContext } from '../components/FormEditor/Context'; import type { Input } from '../components/Forms/validationHelpers'; -import { hasNativeErrors, isInputTouched } from '../components/Forms/validationHelpers'; +import { + hasNativeErrors, + isInputTouched, +} from '../components/Forms/validationHelpers'; import { listen } from '../utils/events'; import type { RA } from '../utils/types'; From 6b77369bc369ca6b23b8c309878b07a11b8c8d73 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 18 Mar 2026 12:53:05 -0500 Subject: [PATCH 04/39] fix: prevent users from generating tokens to collections they don't have access to --- specifyweb/backend/accounts/middleware.py | 11 ++++++++--- specifyweb/backend/accounts/views.py | 6 +++--- specifyweb/backend/context/views.py | 5 ++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py index 1980aa06492..bf60754d92f 100644 --- a/specifyweb/backend/accounts/middleware.py +++ b/specifyweb/backend/accounts/middleware.py @@ -1,11 +1,10 @@ -import jwt - -from django.contrib.auth import login, authenticate from django.utils.functional import SimpleLazyObject +from django.core.exceptions import PermissionDenied from specifyweb.specify.models import Collection, Specifyuser, Agent from specifyweb.specify.api.filter_by_col import filter_by_collection from specifyweb.backend.accounts.auth_token_utils import get_token_from_request, token_is_revoked +from specifyweb.backend.context.views import has_collection_access def get_agent(request): try: @@ -25,6 +24,12 @@ def __call__(self, request): return self.get_response(request) user_id = token["sub"] collection_id = token["collection"] + + # This shouldn't happen in practice as this is also enforced when the + # tokens are generated, but just in case a token is forged this + # prevents users from accessing Collections they shouldn't + if not has_collection_access(collection_id, user_id): + raise PermissionDenied() request.specify_collection = SimpleLazyObject(lambda: Collection.objects.get(id=collection_id)) lazy_user = SimpleLazyObject(lambda: Specifyuser.objects.get(id=user_id)) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 0468fe8ec8d..74957bf6dc5 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -27,6 +27,7 @@ from specifyweb.backend.accounts.permissions_types import InviteLinkPT, SetPasswordPT, SetUserAgentsPT, Sp6AdminPT, UserOICProvidersPT from specifyweb.backend.accounts.types import ExternalUser, InviteToken, OAuthLogin, ProviderConf, ProviderInfo from specifyweb.middleware.general import require_GET, require_http_methods +from specifyweb.backend.context.views import has_collection_access, set_collection_cookie, users_collections_for_sp7 from specifyweb.backend.permissions.permissions import check_permission_targets from specifyweb.specify import models as spmodels @@ -294,7 +295,6 @@ def choose_collection(request) -> http.HttpResponse: through here, we also use the opportunity to associate an external id to the user if one is provided. """ - from specifyweb.backend.context.views import set_collection_cookie, users_collections_for_sp7 from specifyweb.backend.setup_tool.api import filter_ready_collections_for_config_tasks @@ -615,7 +615,7 @@ def set_admin_status(request, userid): "responses": { "201": {"description": "The auth token was successfully generated", }, "400": {"description": "One of the required keys was not supplied, or one or more keys were supplied incorrectly"}, - "403": {"description": "The provided credentials were incorrect"}, + "403": {"description": "The provided credentials were incorrect, or the user does not have access to the collection"}, "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} } }, @@ -641,7 +641,7 @@ def acquire_auth_token(request): user = authenticate(username=username, password=password) - if user is None: + if user is None or not has_collection_access(collection.id, user.id): return http.HttpResponseForbidden() token = generate_auth_token(user, collection.id, expires_in=expires_in) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 87861acc126..d95c9a77165 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -57,11 +57,14 @@ def users_collections_for_sp6(cursor, user_id): # pragma: no cover return list(cursor.fetchall()) +def has_collection_access(collection_id: int, user_id: int) -> bool: + return query_pt(collection_id, user_id, CollectionAccessPT.access).allowed + def users_collections_for_sp7(userid: int) -> list: # pragma: no cover return [ c for c in Collection.objects.all() - if query_pt(c.id, userid, CollectionAccessPT.access).allowed + if has_collection_access(c.id, userid) ] def set_users_collections_for_sp6(cursor, user, collectionids): # pragma: no cover From 56915b391ccdecf5b1b77ab6186949da3096c509 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 19 Mar 2026 07:57:57 -0500 Subject: [PATCH 05/39] fix: properly scope collections when cookie is not set --- specifyweb/backend/context/middleware.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/context/middleware.py b/specifyweb/backend/context/middleware.py index d9fb2c81681..5e98a2f2a91 100644 --- a/specifyweb/backend/context/middleware.py +++ b/specifyweb/backend/context/middleware.py @@ -3,11 +3,11 @@ """ from django.conf import settings -from django.http import HttpResponseBadRequest from django.utils.functional import SimpleLazyObject from specifyweb.specify.api.filter_by_col import filter_by_collection from specifyweb.specify.models import Collection, Specifyuser, Agent +from specifyweb.backend.context.views import users_collections_for_sp7 def get_cached(attr, func, request): @@ -31,7 +31,16 @@ def get_collection(request): try: collection_id = int(request.COOKIES.get('collection', '')) except ValueError: - return qs.all()[0] + """ + If the collection cookie is not set, try and scope the request to a + collection the user has access to. + If the user can't be inferred, default to the first collection. + """ + user = request.specify_user + if user is None: + return qs.all()[0] + user_collections = users_collections_for_sp7(user.id) + return user_collections[0] else: return qs.get(id=collection_id) From 22a81ce3b4fa32c01b3a5bb92414bc1fe76bb917 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 19 Mar 2026 08:15:09 -0500 Subject: [PATCH 06/39] chore: rename auth token to better fit spec, improve openapi schema --- .../backend/accounts/auth_token_utils.py | 4 +- specifyweb/backend/accounts/urls.py | 4 +- specifyweb/backend/accounts/views.py | 55 +++++++++++++------ 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/specifyweb/backend/accounts/auth_token_utils.py b/specifyweb/backend/accounts/auth_token_utils.py index 20ebef50276..d9deda89dbd 100644 --- a/specifyweb/backend/accounts/auth_token_utils.py +++ b/specifyweb/backend/accounts/auth_token_utils.py @@ -21,7 +21,7 @@ AUTH_TOKEN_ALGORITHMS = ["HS256"] -def generate_auth_token(user, collection_id: int, expires_in: int = DEFAULT_AUTH_LIFESPAN_SECONDS): +def generate_access_token(user, collection_id: int, expires_in: int = DEFAULT_AUTH_LIFESPAN_SECONDS): jti = str(uuid.uuid4()) jwt_payload = { @@ -36,7 +36,7 @@ def generate_auth_token(user, collection_id: int, expires_in: int = DEFAULT_AUTH return token -def revoke_auth_token(token: dict): +def revoke_access_token(token: dict): """ Accepts and revokes a decoded JWT Auth Token. Specifically, stores the token in a "blacklist" in Redis for the remaining diff --git a/specifyweb/backend/accounts/urls.py b/specifyweb/backend/accounts/urls.py index 3555b1028ca..d1120b4808a 100644 --- a/specifyweb/backend/accounts/urls.py +++ b/specifyweb/backend/accounts/urls.py @@ -13,8 +13,8 @@ # OpenId Connect callback endpoint: path('oic_callback/', views.oic_callback), - path('token/', views.acquire_auth_token), - path('token/revoke/', views.revoke_auth_token), + path('token/', views.acquire_access_token), + path('token/revoke/', views.revoke_access_token), path( 'logout/', diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 74957bf6dc5..8e79fc2c71f 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -37,7 +37,7 @@ from django.views.decorators.http import require_POST from specifyweb.backend.permissions.permissions import check_permission_targets from specifyweb.specify.auth.support_login import b64_url_to_bytes -from specifyweb.backend.accounts.auth_token_utils import DEFAULT_AUTH_LIFESPAN_SECONDS, generate_auth_token, revoke_auth_token as revoke_token, AUTH_JWT_DECODE_OPTIONS, AUTH_TOKEN_ALGORITHMS +from specifyweb.backend.accounts.auth_token_utils import DEFAULT_AUTH_LIFESPAN_SECONDS, generate_access_token, revoke_access_token as revoke_token, AUTH_JWT_DECODE_OPTIONS, AUTH_TOKEN_ALGORITHMS from django.db import transaction, connection logger = logging.getLogger(__name__) @@ -581,11 +581,12 @@ def set_admin_status(request, userid): user.clear_admin() return http.HttpResponse('false', content_type='text/plain') + @openapi(schema={ 'post': { "requestBody": { "required": True, - "description": "Obtain an auhtorization token that can be used with the API", + "description": "Obtain an access token that can be used with the API", "content": { "application/x-www-form-urlencoded": { "schema": { @@ -613,7 +614,25 @@ def set_admin_status(request, userid): } }, "responses": { - "201": {"description": "The auth token was successfully generated", }, + "201": { + "description": "The access token was successfully generated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "int", + "description": "The number of seconds the access token is live for." + } + } + } + } + } + }, "400": {"description": "One of the required keys was not supplied, or one or more keys were supplied incorrectly"}, "403": {"description": "The provided credentials were incorrect, or the user does not have access to the collection"}, "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} @@ -622,7 +641,7 @@ def set_admin_status(request, userid): }) @require_POST @csrf_exempt -def acquire_auth_token(request): +def acquire_access_token(request): username = request.POST.get("username") password = request.POST.get("password") collection_id = request.POST.get("collectionid") @@ -644,38 +663,42 @@ def acquire_auth_token(request): if user is None or not has_collection_access(collection.id, user.id): return http.HttpResponseForbidden() - token = generate_auth_token(user, collection.id, expires_in=expires_in) + token = generate_access_token(user, collection.id, expires_in=expires_in) # TODO: lower default expiry time and also issue refresh tokens that can be # used to re-issue auth tokens # Issue refresh tokens as HTTP-only cookies? Need to keep CSRF protection # and replay attacks in mind. Maybe just store them in Redis... - response = http.JsonResponse({"auth_token": token}) - return response + response = { + "access_token": token, + "expires_in": expires_in + } + return http.JsonResponse(response) + @openapi(schema={ 'post': { "requestBody": { "required": True, - "description": "Revoke a previously granted authorization token", + "description": "Revoke a previously granted access token", "content": { "application/x-www-form-urlencoded": { "schema": { "type": "object", "properties": { - "token": { + "access_token": { "type": "string", - "description": "The JWT Auth token to revoke" + "description": "The JWT Access token to revoke" }, }, - "required": ['token'], + "required": ['access_token'], 'additionalProperties': False } } } }, "responses": { - "204": {"description": "The auth token was revoked"}, + "204": {"description": "The access token was revoked"}, "400": {"description": "The provided token is already invalid or could not be validated"}, "403": {"description": "The user was not logged-in when making a request to the endpoint"}, "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} @@ -684,15 +707,15 @@ def acquire_auth_token(request): }) @require_POST @login_maybe_required -def revoke_auth_token(request): - encoded_token = request.POST.get("token") +def revoke_access_token(request): + encoded_token = request.POST.get("access_token") try: - token = jwt.decode(encoded_token, settings.SECRET_KEY, options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) + token = jwt.decode(encoded_token, settings.SECRET_KEY, + options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) except jwt.exceptions.InvalidTokenError: return http.HttpResponseBadRequest() revoke_token(token) return http.HttpResponse('', status=204) - From 53dd4bb5782dbfeda8aa76c97b68203fcdfb57c2 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 19 Mar 2026 08:40:19 -0500 Subject: [PATCH 07/39] feat: improve entropy of default secret key --- Dockerfile | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7f05311a171..71ca5073056 100644 --- a/Dockerfile +++ b/Dockerfile @@ -230,8 +230,19 @@ EOF RUN echo "import os \nDEBUG = os.getenv('SP7_DEBUG', '').lower() == 'true'\n" \ > settings/debug.py -RUN echo "import os \nSECRET_KEY = os.environ['SECRET_KEY']\n" \ - > settings/secret_key.py +RUN cat <<'EOF' > settings/secret_key.py +import os +import secrets + +current_key = os.getenv('SECRET_KEY') + +if current_key is None or current_key.strip() == "" or current_key.strip().replace(" ", "_") == "change_this_to_some_unique_random_string": + new_key = secrets.token_hex(16) +else: + new_key = current_key + +SECRET_KEY = new_key +EOF ENV LC_ALL=C.UTF-8 ENV LANG=C.UTF-8 From 930bdba95af1487a0379f5009b8eeb9c1f33019a Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 19 Mar 2026 08:50:42 -0500 Subject: [PATCH 08/39] fix: handle case when user has no access to no collections --- specifyweb/backend/context/middleware.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/specifyweb/backend/context/middleware.py b/specifyweb/backend/context/middleware.py index 5e98a2f2a91..551bb335f79 100644 --- a/specifyweb/backend/context/middleware.py +++ b/specifyweb/backend/context/middleware.py @@ -40,6 +40,15 @@ def get_collection(request): if user is None: return qs.all()[0] user_collections = users_collections_for_sp7(user.id) + # Blegh, if the user doesn't have permission to any collections, use + # the default behavior of the first collection. + # Essentially, put the burden of authorization on the view: which is + # much easier to slip-up and make a mistake in (especially if the + # author is acting on implicit assumptions about security at the time + # of the view) + # TODO: handle such cases in this middleware. + if len(user_collections) <= 0: + return qs.all()[0] return user_collections[0] else: return qs.get(id=collection_id) From d41937e2616facb23032742b9cc313a74d20b40e Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 19 Mar 2026 09:17:43 -0500 Subject: [PATCH 09/39] fix: invalid type in jsonschema openapi spec --- specifyweb/backend/accounts/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 8e79fc2c71f..99c25023536 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -625,7 +625,7 @@ def set_admin_status(request, userid): "type": "string" }, "expires_in": { - "type": "int", + "type": "integer", "description": "The number of seconds the access token is live for." } } From cddd865b187424327674432d5dfde3dc0e986633 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 19 Mar 2026 13:20:00 -0500 Subject: [PATCH 10/39] feat: shortcircut middleware when token is present but invalid --- specifyweb/backend/accounts/auth_token_utils.py | 6 +++--- specifyweb/backend/accounts/middleware.py | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/specifyweb/backend/accounts/auth_token_utils.py b/specifyweb/backend/accounts/auth_token_utils.py index d9deda89dbd..30b5ac2b3fc 100644 --- a/specifyweb/backend/accounts/auth_token_utils.py +++ b/specifyweb/backend/accounts/auth_token_utils.py @@ -53,11 +53,11 @@ def revoke_access_token(token: dict): blacklist_ttl = expires_at - current_time set_string(f"revoked:{jti}", "true", time_to_live=blacklist_ttl) -def get_token_from_request(request) -> Literal[False] | dict: +def get_token_from_request(request) -> Literal[False] | None | dict: auth_header = request.headers.get("Authorization") if auth_header is None or not auth_header.startswith("Bearer "): - return False - + return None + encoded_token = auth_header.split(" ")[1] try: diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py index bf60754d92f..aa5e3a0e905 100644 --- a/specifyweb/backend/accounts/middleware.py +++ b/specifyweb/backend/accounts/middleware.py @@ -1,5 +1,6 @@ from django.utils.functional import SimpleLazyObject from django.core.exceptions import PermissionDenied +from django.http import HttpResponse from specifyweb.specify.models import Collection, Specifyuser, Agent from specifyweb.specify.api.filter_by_col import filter_by_collection @@ -20,8 +21,15 @@ def __init__(self, get_response): def __call__(self, request): token = get_token_from_request(request) - if token == False or token_is_revoked(token): + # The request doesn't have an access token, so pass through + if token is None: return self.get_response(request) + + # There was an access token in the request, but it was invalid or + # revoked. Stop here and return a 401 Unauthorized + if token == False or token_is_revoked(token): + return HttpResponse('Invalid access token', status_code=401) + user_id = token["sub"] collection_id = token["collection"] From 8aa2a0c0f359aa2a7f2accae2c6cbbee81c28924 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 19 Mar 2026 14:07:47 -0500 Subject: [PATCH 11/39] feat: return proper WWW-Authenticate headers for invalid tokens --- specifyweb/backend/accounts/middleware.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py index aa5e3a0e905..6280e2dbaaf 100644 --- a/specifyweb/backend/accounts/middleware.py +++ b/specifyweb/backend/accounts/middleware.py @@ -28,7 +28,9 @@ def __call__(self, request): # There was an access token in the request, but it was invalid or # revoked. Stop here and return a 401 Unauthorized if token == False or token_is_revoked(token): - return HttpResponse('Invalid access token', status_code=401) + response = HttpResponse('Invalid access token', status=401) + response["WWW-Authenticate"] = 'error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"' + return response user_id = token["sub"] collection_id = token["collection"] From 34b54a0e04b1edf6b65df155954782d14f5e4d7e Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 19 Mar 2026 14:55:23 -0500 Subject: [PATCH 12/39] fix: correct return code in openapi jsonschema --- specifyweb/backend/accounts/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 99c25023536..578a7f6dac9 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -614,7 +614,7 @@ def set_admin_status(request, userid): } }, "responses": { - "201": { + "200": { "description": "The access token was successfully generated", "content": { "application/json": { From bb839797d8bc8162af09f9ce9c539edea3403ca5 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 19 Mar 2026 15:42:06 -0500 Subject: [PATCH 13/39] feat: parse expiry time as int --- specifyweb/backend/accounts/views.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 578a7f6dac9..28355e07dc5 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -645,13 +645,18 @@ def acquire_access_token(request): username = request.POST.get("username") password = request.POST.get("password") collection_id = request.POST.get("collectionid") - expires_in = request.POST.get("expires", DEFAULT_AUTH_LIFESPAN_SECONDS) + raw_expires_in = request.POST.get("expires", DEFAULT_AUTH_LIFESPAN_SECONDS) if None in (username, password, collection_id): return http.HttpResponseBadRequest(f"username, password, and collection are required") - if not isinstance(expires_in, int) or expires_in <= 0: - return http.HttpResponseBadRequest(f"Invalid expiry time for token") + try: + expires_in = int(raw_expires_in) + if expires_in <= 0: + return http.HttpResponseBadRequest(f"Invalid expiry time") + except ValueError: + return http.HttpResponseBadRequest(f"Expiry time could be parsed as integer") + try: collection = Collection.objects.get(id=collection_id) From 4babcbb3411ef056b3e6b1381b06391afe5f2cd2 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 26 Mar 2026 15:31:56 -0500 Subject: [PATCH 14/39] fix: generate key at build time rather than import time --- Dockerfile | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 71ca5073056..415459e5da0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -230,18 +230,17 @@ EOF RUN echo "import os \nDEBUG = os.getenv('SP7_DEBUG', '').lower() == 'true'\n" \ > settings/debug.py -RUN cat <<'EOF' > settings/secret_key.py +RUN cat < settings/secret_key.py import os -import secrets +DEFAULT_KEY="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 50)" +CURRENT_KEY=os.getenv("SECRET_KEY") -current_key = os.getenv('SECRET_KEY') - -if current_key is None or current_key.strip() == "" or current_key.strip().replace(" ", "_") == "change_this_to_some_unique_random_string": - new_key = secrets.token_hex(16) +if CURRENT_KEY is None or CURRENT_KEY.strip() == "" or CURRENT_KEY.strip().replace(" ", "_") == "change_this_to_some_unique_random_string": + new_key = DEFAULT_KEY else: - new_key = current_key + new_key = CURRENT_KEY -SECRET_KEY = new_key +SECRET_KEY=new_key EOF ENV LC_ALL=C.UTF-8 From a0a8af1159ab33fc907d49f3f5a2d08e5388e951 Mon Sep 17 00:00:00 2001 From: Jason Melton <64045831+melton-jason@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:01:46 +0000 Subject: [PATCH 15/39] Lint code with ESLint and Prettier Triggered by 7d75944531c5847298892fee8b7fe55a8149c8c5 on branch refs/heads/issue-5163 --- .../lib/components/Notifications/NotificationRenderers.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index 26f4696c407..790b3373284 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -395,9 +395,7 @@ export const notificationRenderers: IR< ); }, 'collection-creation-starting'() { - return ( -

{setupToolText.collectionCreationStarted()}

- ); + return

{setupToolText.collectionCreationStarted()}

; }, default(notification) { console.error('Unknown notification type', { notification }); From 766ee181d019899dc0159d9463548c676942e90a Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 22 Apr 2026 08:08:54 -0500 Subject: [PATCH 16/39] chore: improve documentation and formatting --- ...h_token_utils.py => access_token_utils.py} | 8 ++++-- specifyweb/backend/accounts/middleware.py | 26 ++++++++++++------- specifyweb/backend/accounts/views.py | 2 +- 3 files changed, 23 insertions(+), 13 deletions(-) rename specifyweb/backend/accounts/{auth_token_utils.py => access_token_utils.py} (88%) diff --git a/specifyweb/backend/accounts/auth_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py similarity index 88% rename from specifyweb/backend/accounts/auth_token_utils.py rename to specifyweb/backend/accounts/access_token_utils.py index 30b5ac2b3fc..d9c1b69d694 100644 --- a/specifyweb/backend/accounts/auth_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -21,6 +21,7 @@ AUTH_TOKEN_ALGORITHMS = ["HS256"] + def generate_access_token(user, collection_id: int, expires_in: int = DEFAULT_AUTH_LIFESPAN_SECONDS): jti = str(uuid.uuid4()) @@ -32,7 +33,8 @@ def generate_access_token(user, collection_id: int, expires_in: int = DEFAULT_AU "iat": datetime.now(timezone.utc), "exp": datetime.now(timezone.utc) + timedelta(seconds=expires_in) } - token = jwt.encode(jwt_payload, settings.SECRET_KEY, algorithm=AUTH_TOKEN_ALGORITHMS[0]) + token = jwt.encode(jwt_payload, settings.SECRET_KEY, + algorithm=AUTH_TOKEN_ALGORITHMS[0]) return token @@ -53,6 +55,7 @@ def revoke_access_token(token: dict): blacklist_ttl = expires_at - current_time set_string(f"revoked:{jti}", "true", time_to_live=blacklist_ttl) + def get_token_from_request(request) -> Literal[False] | None | dict: auth_header = request.headers.get("Authorization") if auth_header is None or not auth_header.startswith("Bearer "): @@ -61,7 +64,8 @@ def get_token_from_request(request) -> Literal[False] | None | dict: encoded_token = auth_header.split(" ")[1] try: - token = jwt.decode(encoded_token, settings.SECRET_KEY, options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) + token = jwt.decode(encoded_token, settings.SECRET_KEY, + options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) except jwt.exceptions.InvalidTokenError: return False return token diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py index 6280e2dbaaf..6a394c4f705 100644 --- a/specifyweb/backend/accounts/middleware.py +++ b/specifyweb/backend/accounts/middleware.py @@ -4,9 +4,10 @@ from specifyweb.specify.models import Collection, Specifyuser, Agent from specifyweb.specify.api.filter_by_col import filter_by_collection -from specifyweb.backend.accounts.auth_token_utils import get_token_from_request, token_is_revoked +from specifyweb.backend.accounts.access_token_utils import get_token_from_request, token_is_revoked from specifyweb.backend.context.views import has_collection_access + def get_agent(request): try: return filter_by_collection(Agent.objects, request.specify_collection) \ @@ -15,10 +16,11 @@ def get_agent(request): except Agent.DoesNotExist: return None + class JWTAuthMiddleware: def __init__(self, get_response): self.get_response = get_response - + def __call__(self, request): token = get_token_from_request(request) # The request doesn't have an access token, so pass through @@ -34,23 +36,27 @@ def __call__(self, request): user_id = token["sub"] collection_id = token["collection"] - - # This shouldn't happen in practice as this is also enforced when the - # tokens are generated, but just in case a token is forged this - # prevents users from accessing Collections they shouldn't + + # This shouldn't happen often in practice as this is also enforced when + # the tokens are generated, but just in case a token is forged or the + # user's collection access was revoked since the token was generated, + # this prevents users from accessing Collections they shouldn't if not has_collection_access(collection_id, user_id): raise PermissionDenied() - request.specify_collection = SimpleLazyObject(lambda: Collection.objects.get(id=collection_id)) - lazy_user = SimpleLazyObject(lambda: Specifyuser.objects.get(id=user_id)) + request.specify_collection = SimpleLazyObject( + lambda: Collection.objects.get(id=collection_id)) + lazy_user = SimpleLazyObject( + lambda: Specifyuser.objects.get(id=user_id)) request.specify_user = lazy_user request.user = lazy_user - request.specify_user_agent = SimpleLazyObject(lambda: get_agent(request)) + request.specify_user_agent = SimpleLazyObject( + lambda: get_agent(request)) # We can disable CSRF checks with users authenticated via JWT. # This is ONLY because the end user must explicitly pass the auth token # as a header, and is not stored within the session, cookies, etc. - # Essentially, with CSRF protection disabled for users authenticated + # Essentially, with CSRF protection disabled for users authenticated # via token, we have to be careful not to store any auth information in # a stateful way within the session # e.g., avoid calling django.contrib.auth.login diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 28355e07dc5..6068e8f209d 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -37,7 +37,7 @@ from django.views.decorators.http import require_POST from specifyweb.backend.permissions.permissions import check_permission_targets from specifyweb.specify.auth.support_login import b64_url_to_bytes -from specifyweb.backend.accounts.auth_token_utils import DEFAULT_AUTH_LIFESPAN_SECONDS, generate_access_token, revoke_access_token as revoke_token, AUTH_JWT_DECODE_OPTIONS, AUTH_TOKEN_ALGORITHMS +from specifyweb.backend.accounts.access_token_utils import DEFAULT_AUTH_LIFESPAN_SECONDS, generate_access_token, revoke_access_token as revoke_token, AUTH_JWT_DECODE_OPTIONS, AUTH_TOKEN_ALGORITHMS from django.db import transaction, connection logger = logging.getLogger(__name__) From e9015164aee113024323e1740ae943ec5042e52f Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 27 Oct 2026 15:49:12 -0500 Subject: [PATCH 17/39] fix: ensure types when serializing and deserailizing access tokens --- specifyweb/backend/accounts/access_token_utils.py | 2 +- specifyweb/backend/accounts/middleware.py | 4 ++-- specifyweb/backend/accounts/views.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index cd05cf8122a..23e558846bb 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -26,7 +26,7 @@ def generate_access_token(user, collection_id: int, expires_in: int = DEFAULT_AU jti = str(uuid.uuid4()) jwt_payload = { - "sub": user.id, + "sub": str(user.id), "username": user.name, "collection": collection_id, "jti": jti, diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py index 6a394c4f705..3f35b7fbaf3 100644 --- a/specifyweb/backend/accounts/middleware.py +++ b/specifyweb/backend/accounts/middleware.py @@ -34,8 +34,8 @@ def __call__(self, request): response["WWW-Authenticate"] = 'error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"' return response - user_id = token["sub"] - collection_id = token["collection"] + user_id = int(token["sub"]) + collection_id = int(token["collection"]) # This shouldn't happen often in practice as this is also enforced when # the tokens are generated, but just in case a token is forged or the diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 6c399940bfb..af702127dc7 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -633,7 +633,7 @@ def set_admin_status(request, userid): }, "expires": { "type": "integer", - "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS / 60} minutes if not specified", + "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS} seconds if not specified", "default": DEFAULT_AUTH_LIFESPAN_SECONDS } }, From 9e99ec551ff8925cbab554bcb0c40c86e01ca3b9 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 27 Oct 2026 15:53:08 -0500 Subject: [PATCH 18/39] fix: align header when token is invalid to RFC 6750 --- specifyweb/backend/accounts/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py index 3f35b7fbaf3..17b36b88642 100644 --- a/specifyweb/backend/accounts/middleware.py +++ b/specifyweb/backend/accounts/middleware.py @@ -31,7 +31,7 @@ def __call__(self, request): # revoked. Stop here and return a 401 Unauthorized if token == False or token_is_revoked(token): response = HttpResponse('Invalid access token', status=401) - response["WWW-Authenticate"] = 'error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"' + response["WWW-Authenticate"] = 'Bearer error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"' return response user_id = int(token["sub"]) From d642c097f8b852f45d2a4518388e9e46f0772510 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 28 Oct 2026 08:21:50 -0500 Subject: [PATCH 19/39] chore: add comment linking to RFC for WWW-Authenticate header --- specifyweb/backend/accounts/middleware.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py index 17b36b88642..f5a99a06723 100644 --- a/specifyweb/backend/accounts/middleware.py +++ b/specifyweb/backend/accounts/middleware.py @@ -31,6 +31,8 @@ def __call__(self, request): # revoked. Stop here and return a 401 Unauthorized if token == False or token_is_revoked(token): response = HttpResponse('Invalid access token', status=401) + # This header is formatted per OAuth 2.0, see section 3 of RFC 6750 + # See https://datatracker.ietf.org/doc/html/rfc6750#section-3 response["WWW-Authenticate"] = 'Bearer error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"' return response From bf1bd4f87225c972c01a7fd490ce4f1471b30e41 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 28 Oct 2026 09:13:35 -0500 Subject: [PATCH 20/39] chore: more OAuth 2.0 information in comments --- specifyweb/backend/accounts/middleware.py | 15 +++++++++++++-- specifyweb/backend/accounts/views.py | 5 +++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py index f5a99a06723..86463acd0f7 100644 --- a/specifyweb/backend/accounts/middleware.py +++ b/specifyweb/backend/accounts/middleware.py @@ -18,6 +18,13 @@ def get_agent(request): class JWTAuthMiddleware: + """ + This middleware is in charge of handling requests that contain a JWT + Authorization Bearer Token following the Auth 2.0 framework. + See RFCs 6749 and 6750 for the Auth 2.0 spec: + https://www.rfc-editor.org/info/rfc6749/ + https://www.rfc-editor.org/info/rfc6750/ + """ def __init__(self, get_response): self.get_response = get_response @@ -31,11 +38,15 @@ def __call__(self, request): # revoked. Stop here and return a 401 Unauthorized if token == False or token_is_revoked(token): response = HttpResponse('Invalid access token', status=401) - # This header is formatted per OAuth 2.0, see section 3 of RFC 6750 - # See https://datatracker.ietf.org/doc/html/rfc6750#section-3 + # This header is formatted per OAuth 2.0, see section 5.2 of RFC + # 6749 and section 3 of RFC 6750 + # See https://www.rfc-editor.org/info/rfc6749/#section-5.2 and + # https://www.rfc-editor.org/info/rfc6750/#section-3 response["WWW-Authenticate"] = 'Bearer error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"' return response + # REFACTOR: explicitly handle cases where these values can't be cast as + # an integer? user_id = int(token["sub"]) collection_id = int(token["collection"]) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index af702127dc7..cf5ada7f50c 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -672,6 +672,11 @@ def set_admin_status(request, userid): @require_POST @csrf_exempt def acquire_access_token(request): + # FEAT: Currently we only accept x-www-form-urlencoded because that is + # required by the OAuth 2.0 framework, see section 4.4.2 of RFC 6749: + # https://www.rfc-editor.org/info/rfc6749/#section-4.4.2 + # Though in the future we can allow this endpoint to also accept + # application/json bodies username = request.POST.get("username") password = request.POST.get("password") collection_id = request.POST.get("collectionid") From 711ce71f3118b90ee50d9049a6b02404c13a47c8 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 28 Oct 2026 11:47:58 -0500 Subject: [PATCH 21/39] feat: more closely follow OAuth2.0 spec and allow access token extension --- .../backend/accounts/access_token_utils.py | 83 ++++++++++ specifyweb/backend/accounts/views.py | 147 +++++++++++------- 2 files changed, 173 insertions(+), 57 deletions(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index 23e558846bb..e61c9d1ee3e 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -5,9 +5,13 @@ from datetime import datetime, timezone, timedelta from typing import Literal +from django import http +from django.contrib.auth import authenticate from django.conf import settings +from specifyweb.specify.models import Collection from specifyweb.backend.cache.redis.connect import RedisConnection, RedisString +from specifyweb.backend.context.views import has_collection_access DEFAULT_AUTH_LIFESPAN_SECONDS = 1800 @@ -78,3 +82,82 @@ def token_is_revoked(token: dict): redis_string = RedisString(RedisConnection()) token_key = f"revoked:{token_identifier}" return redis_string.exists(token_key) + +class ACCESS_TOKEN_GRANT_TYPE: + # The client provides the server with a username, password, and collection + # id + PASSWORD = "password" + +def handle_password_token_grant(request: http.HttpRequest) -> http.HttpResponse: + """ + The client indiciated they want to authenticate using username and password. + They should have also provided a collection id to scope the token to. + + See Section 4.3 of RFC 6749 for more information about this specific grant + type according to the OAuth 2.0 Framework: + https://www.rfc-editor.org/info/rfc6749/#section-4.3 + """ + username = request.POST.get("username") + password = request.POST.get("password") + raw_collection_id = request.POST.get("collectionid") + raw_expires_in = request.POST.get("expires", DEFAULT_AUTH_LIFESPAN_SECONDS) + + if None in (username, password, raw_collection_id): + error_response = { + "error": "invalid_request", + "error_description": "username, password, and collectionid are required" + } + return http.JsonResponse(error_response, status=400) + + try: + expires_in = int(raw_expires_in) + if expires_in <= 0: + error_response = { + "error": "invalid_request", + "error_description": "Token expiry time must be greater than 0" + } + return http.JsonResponse(error_response, status=400) + except ValueError: + error_response = { + "error": "invalid_request", + "error_description": "Expiry time could not be parsed as integer" + } + return http.JsonResponse(error_response, status=400) + + try: + collection_id = int(raw_collection_id) + except ValueError: + error_response = { + "error": "invalid_request", + "error_description": "Collection ID could not be parsed as integer" + } + return http.JsonResponse(error_response, status=400) + + try: + collection = Collection.objects.get(id=collection_id) + except Collection.DoesNotExist: + error_response = { + "error": "invalid_request", + "error_description": f"Collection {collection_id} does not exist" + } + return http.JsonResponse(error_response, status=400) + + user = authenticate(username=username, password=password) + + if user is None or not has_collection_access(collection.id, user.id): + error_response = { + "error": "invalid_client", + "error_description": "Credentials were incorrect or user does not have access to the Collection" + } + return http.JsonResponse(error_response, status=401) + + token = generate_access_token(user, collection.id, expires_in=expires_in) + + # TODO: issue refresh tokens that can be used to re-issue auth tokens + # Issue refresh tokens as HTTP-only cookies? Need to keep CSRF protection + # and replay attacks in mind. Maybe just store them in Redis... + response = { + "access_token": token, + "expires_in": expires_in + } + return http.JsonResponse(response) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index cf5ada7f50c..e4b25f9b495 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -35,7 +35,13 @@ from .models import Spuserexternalid, UserPresence from specifyweb.specify.models import Specifyuser, Agent, Collection from specifyweb.specify.auth.support_login import b64_url_to_bytes -from specifyweb.backend.accounts.access_token_utils import DEFAULT_AUTH_LIFESPAN_SECONDS, generate_access_token, revoke_access_token as revoke_token, AUTH_JWT_DECODE_OPTIONS, AUTH_TOKEN_ALGORITHMS +from specifyweb.backend.accounts.access_token_utils import ( + DEFAULT_AUTH_LIFESPAN_SECONDS, + AUTH_JWT_DECODE_OPTIONS, + AUTH_TOKEN_ALGORITHMS, + ACCESS_TOKEN_GRANT_TYPE, + revoke_access_token as revoke_token, + handle_password_token_grant) logger = logging.getLogger(__name__) @@ -616,29 +622,39 @@ def set_admin_status(request, userid): 'post': { "requestBody": { "required": True, - "description": "Obtain an access token that can be used with the API", + "description": "Obtain an access token that can be used with the API. This endpoint follows OAuth 2.0 Framework defined in RFC 6749: https://www.rfc-editor.org/info/rfc6749/. Specify supports using bearer access tokens through an Authorization Bearer header as defined in RFC 6750: https://www.rfc-editor.org/info/rfc6750/", "content": { "application/x-www-form-urlencoded": { "schema": { - "type": "object", - "properties": { - "username": { - "type": "string" - }, - "password": { - "type": "string" - }, - "collectionid": { - "type": "integer" - }, - "expires": { - "type": "integer", - "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS} seconds if not specified", - "default": DEFAULT_AUTH_LIFESPAN_SECONDS + "oneOf": [ + # FEAT: Add the refresh_token grant type, to + # generate access tokens + { + "type": "object", + "properties": { + "grant_type": { + "type": "string", + "enum": [ACCESS_TOKEN_GRANT_TYPE.PASSWORD] + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "collectionid": { + "type": "integer" + }, + "expires": { + "type": "integer", + "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS} seconds if not specified", + "default": DEFAULT_AUTH_LIFESPAN_SECONDS + } + }, + "required": ['grant_type', 'username', 'password', 'collectionid'], + 'additionalProperties': False } - }, - "required": ['username', 'password', 'collectionid'], - 'additionalProperties': False + ] } } } @@ -663,57 +679,74 @@ def set_admin_status(request, userid): } } }, - "400": {"description": "One of the required keys was not supplied, or one or more keys were supplied incorrectly"}, - "403": {"description": "The provided credentials were incorrect, or the user does not have access to the collection"}, + "400": { + "description": "The request for an access token was invalid. See the error_description for more details. The error types are described in Section 5.2 of RFC 6749: https://www.rfc-editor.org/info/rfc6749/#section-5.2", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "error_description": { + "type": "string" + }, + "required": ["error"] + } + } + } + } + }, + "401": { + "description": "The provided credentials were incorrect, or the user does not have access to the collection", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["invalid_client"] + }, + "error_description": { + "type": "string" + }, + "required": ["error"] + } + } + } + } + }, "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} } }, }) @require_POST @csrf_exempt -def acquire_access_token(request): +def acquire_access_token(request: http.HttpRequest): # FEAT: Currently we only accept x-www-form-urlencoded because that is # required by the OAuth 2.0 framework, see section 4.4.2 of RFC 6749: # https://www.rfc-editor.org/info/rfc6749/#section-4.4.2 # Though in the future we can allow this endpoint to also accept # application/json bodies - username = request.POST.get("username") - password = request.POST.get("password") - collection_id = request.POST.get("collectionid") - raw_expires_in = request.POST.get("expires", DEFAULT_AUTH_LIFESPAN_SECONDS) - - if None in (username, password, collection_id): - return http.HttpResponseBadRequest(f"username, password, and collection are required") - - try: - expires_in = int(raw_expires_in) - if expires_in <= 0: - return http.HttpResponseBadRequest(f"Invalid expiry time") - except ValueError: - return http.HttpResponseBadRequest(f"Expiry time could be parsed as integer") + grant_type = require_POST.get("grant_type") + if grant_type is None: + error_response = { + "error": "invalid_request", + "error_description": "A grant_type was not provided in access token request" + } + return http.JsonResponse(error_response, status=400) - try: - collection = Collection.objects.get(id=collection_id) - except Collection.DoesNotExist: - return http.HttpResponseBadRequest(f'collection {collection_id} does not exist') - - user = authenticate(username=username, password=password) - - if user is None or not has_collection_access(collection.id, user.id): - return http.HttpResponseForbidden() - - token = generate_access_token(user, collection.id, expires_in=expires_in) + if grant_type == ACCESS_TOKEN_GRANT_TYPE.PASSWORD: + response = handle_password_token_grant(request) + return response - # TODO: lower default expiry time and also issue refresh tokens that can be - # used to re-issue auth tokens - # Issue refresh tokens as HTTP-only cookies? Need to keep CSRF protection - # and replay attacks in mind. Maybe just store them in Redis... - response = { - "access_token": token, - "expires_in": expires_in + error_response = { + "error": "unsupported_grant_type" } - return http.JsonResponse(response) + return http.JsonResponse(error_response, status=400) @openapi(schema={ @@ -740,7 +773,7 @@ def acquire_access_token(request): "responses": { "204": {"description": "The access token was revoked"}, "400": {"description": "The provided token is already invalid or could not be validated"}, - "403": {"description": "The user was not logged-in when making a request to the endpoint"}, + "403": {"description": "The user was not authenticated/logged-in when making a request to the endpoint"}, "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} } }, From 805c8c9023bbd5fdbf25c501c04afdef63781d74 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 28 Oct 2026 12:41:38 -0500 Subject: [PATCH 22/39] fix: generate default secret key at runtime if default or not specified --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e86cafc3f43..f3848e2ea24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -232,11 +232,11 @@ RUN echo "import os \nDEBUG = os.getenv('SP7_DEBUG', '').lower() == 'true'\n" \ RUN cat < settings/secret_key.py import os -DEFAULT_KEY="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 50)" +import secrets CURRENT_KEY=os.getenv("SECRET_KEY") if CURRENT_KEY is None or CURRENT_KEY.strip() == "" or CURRENT_KEY.strip().replace(" ", "_") == "change_this_to_some_unique_random_string": - new_key = DEFAULT_KEY + new_key = secrets.token_urlsafe() else: new_key = CURRENT_KEY From 8aac18aa390b62706a9bb213816ae9f463ad36a6 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 28 Oct 2026 12:57:09 -0500 Subject: [PATCH 23/39] fix: openapi schema errors and undocumented 404 for set_user_agents --- specifyweb/backend/accounts/views.py | 31 ++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index e4b25f9b495..2cb2da05cd9 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -12,7 +12,7 @@ from django.contrib.auth import login, authenticate from django.db import connection, transaction from django.db.models import Max -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 from django.template.response import TemplateResponse from django.utils import crypto from django.utils import timezone @@ -514,6 +514,9 @@ def set_password(request, userid): } } } + }, + "404": { + "description": "No SpecifyUser with the ID exists" } } }, @@ -522,14 +525,15 @@ def set_password(request, userid): @require_POST def set_user_agents(request, userid: int): "Sets the agents to represent the user in different disciplines." - user = Specifyuser.objects.get(pk=userid) + # BUG: Handle returning 400 code when userid is not an int + user = get_object_or_404(Specifyuser, pk=userid) new_agentids = json.loads(request.body) cursor = connection.cursor() with transaction.atomic(): # clear user's existing agents Agent.objects.filter( - specifyuser_id=userid).update(specifyuser_id=None) + specifyuser_id=user.pk).update(specifyuser_id=None) # check if any of the agents to be assigned are used by other users in_use = Agent.objects.select_for_update().filter( @@ -539,7 +543,7 @@ def set_user_agents(request, userid: int): # assign the new agents Agent.objects.filter( - pk__in=new_agentids).update(specifyuser_id=userid) + pk__in=new_agentids).update(specifyuser_id=user.pk) # check for multiple agents assigned to the user # NOTE: This is too aggressive (and inefficient) of a test. @@ -549,7 +553,7 @@ def set_user_agents(request, userid: int): """select divisionid, a1.agentid, a2.agentid from agent a1 join agent a2 using (specifyuserid, divisionid) where a1.agentid < a2.agentid and specifyuserid = %s - """, [userid] + """, [user.pk] ) multiple = [ @@ -561,14 +565,14 @@ def set_user_agents(request, userid: int): # get the list of collections the agents belong to. collections = Collection.objects.filter( - discipline__division__members__specifyuser_id=userid).values_list('id', flat=True) + discipline__division__members__specifyuser_id=user.pk).values_list('id', flat=True) # check permissions for setting user agents in those collections. for collectionid in collections: check_permission_targets(collectionid, request.specify_user.id, [ SetUserAgentsPT.update]) - check_collection_access_against_agents(userid) + check_collection_access_against_agents(user.pk) return http.HttpResponse('', status=204) @@ -674,7 +678,8 @@ def set_admin_status(request, userid): "type": "integer", "description": "The number of seconds the access token is live for." } - } + }, + "required": ['access_token', 'expires_in'] } } } @@ -692,8 +697,8 @@ def set_admin_status(request, userid): "error_description": { "type": "string" }, - "required": ["error"] - } + }, + "required": ['error'] } } } @@ -712,8 +717,8 @@ def set_admin_status(request, userid): "error_description": { "type": "string" }, - "required": ["error"] - } + }, + "required": ['error'] } } } @@ -730,7 +735,7 @@ def acquire_access_token(request: http.HttpRequest): # https://www.rfc-editor.org/info/rfc6749/#section-4.4.2 # Though in the future we can allow this endpoint to also accept # application/json bodies - grant_type = require_POST.get("grant_type") + grant_type = request.POST.get("grant_type") if grant_type is None: error_response = { From 546550270d260173fa17fe69c2a1d87472b42cf2 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 28 Oct 2026 13:08:53 -0500 Subject: [PATCH 24/39] chore: add type to decode options --- specifyweb/backend/accounts/access_token_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index e61c9d1ee3e..41bc922be3a 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -16,7 +16,7 @@ DEFAULT_AUTH_LIFESPAN_SECONDS = 1800 # See https://pyjwt.readthedocs.io/en/latest/api.html#jwt.decode -AUTH_JWT_DECODE_OPTIONS = { +AUTH_JWT_DECODE_OPTIONS: jwt.types.Options = { "require": ["iat", "exp", "jti"], "verify_signature": True, "verify_iat": True, From bb7ff0ccaccbee795ac328112015aca039fc68de Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 10:39:01 -0500 Subject: [PATCH 25/39] fix: user key from environment for image --- Dockerfile | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index f3848e2ea24..948f6449ae4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -230,18 +230,8 @@ EOF RUN echo "import os \nDEBUG = os.getenv('SP7_DEBUG', '').lower() == 'true'\n" \ > settings/debug.py -RUN cat < settings/secret_key.py -import os -import secrets -CURRENT_KEY=os.getenv("SECRET_KEY") - -if CURRENT_KEY is None or CURRENT_KEY.strip() == "" or CURRENT_KEY.strip().replace(" ", "_") == "change_this_to_some_unique_random_string": - new_key = secrets.token_urlsafe() -else: - new_key = CURRENT_KEY - -SECRET_KEY=new_key -EOF +RUN echo "import os \nSECRET_KEY = os.environ['SECRET_KEY']\n" \ + > settings/secret_key.py ENV LC_ALL=C.UTF-8 ENV LANG=C.UTF-8 From 97e0a135873c0a5b3a2947fa1bc4ff445500c9ca Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 11:03:13 -0500 Subject: [PATCH 26/39] refactor: fix type errors in access_token_utils --- specifyweb/backend/accounts/access_token_utils.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index 41bc922be3a..67e7da63af9 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -3,13 +3,13 @@ import jwt from datetime import datetime, timezone, timedelta -from typing import Literal +from typing import Literal, cast from django import http from django.contrib.auth import authenticate from django.conf import settings -from specifyweb.specify.models import Collection +from specifyweb.specify.models import Collection, Specifyuser from specifyweb.backend.cache.redis.connect import RedisConnection, RedisString from specifyweb.backend.context.views import has_collection_access @@ -125,7 +125,11 @@ def handle_password_token_grant(request: http.HttpRequest) -> http.HttpResponse: return http.JsonResponse(error_response, status=400) try: - collection_id = int(raw_collection_id) + # We know that the raw_collection_id can not be NONE here as that was + # caught by the first check for username, password, and collectionid + # In the worst case scenario where the above check is changed and this + # can be None, this will raise TypeError + collection_id = int(cast(str, raw_collection_id)) except ValueError: error_response = { "error": "invalid_request", @@ -142,7 +146,7 @@ def handle_password_token_grant(request: http.HttpRequest) -> http.HttpResponse: } return http.JsonResponse(error_response, status=400) - user = authenticate(username=username, password=password) + user: Specifyuser = authenticate(username=username, password=password) if user is None or not has_collection_access(collection.id, user.id): error_response = { From 0950bdbb9178f4f065a7ace8fe6482204f6d5452 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 11:17:20 -0500 Subject: [PATCH 27/39] feat: show available grant types when type is missing or unsupported --- specifyweb/backend/accounts/access_token_utils.py | 13 +++++++++---- specifyweb/backend/accounts/views.py | 7 ++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index 67e7da63af9..11e6371257d 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -1,10 +1,10 @@ import uuid -import jwt - from datetime import datetime, timezone, timedelta -from typing import Literal, cast +from typing import Literal, Generator, cast +from enum import StrEnum +import jwt from django import http from django.contrib.auth import authenticate from django.conf import settings @@ -83,11 +83,16 @@ def token_is_revoked(token: dict): token_key = f"revoked:{token_identifier}" return redis_string.exists(token_key) -class ACCESS_TOKEN_GRANT_TYPE: +class ACCESS_TOKEN_GRANT_TYPE(StrEnum): # The client provides the server with a username, password, and collection # id PASSWORD = "password" + @classmethod + def types(cls) -> Generator[str, None, None]: + return (item.value for item in cls) + + def handle_password_token_grant(request: http.HttpRequest) -> http.HttpResponse: """ The client indiciated they want to authenticate using username and password. diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 2cb2da05cd9..700aecc45ef 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -740,16 +740,17 @@ def acquire_access_token(request: http.HttpRequest): if grant_type is None: error_response = { "error": "invalid_request", - "error_description": "A grant_type was not provided in access token request" + "error_description": f"A grant_type was not provided in access token request. Available grant_types: {list(ACCESS_TOKEN_GRANT_TYPE.types())}" } return http.JsonResponse(error_response, status=400) - if grant_type == ACCESS_TOKEN_GRANT_TYPE.PASSWORD: + if grant_type == ACCESS_TOKEN_GRANT_TYPE.PASSWORD.value: response = handle_password_token_grant(request) return response error_response = { - "error": "unsupported_grant_type" + "error": "unsupported_grant_type", + "error_description": f"Available grant_types: {list(ACCESS_TOKEN_GRANT_TYPE.types())}" } return http.JsonResponse(error_response, status=400) From 1bb3989027e08f943f39f2577be7c5f15d0130b4 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 11:19:39 -0500 Subject: [PATCH 28/39] chore: fix union type when authenticating user --- specifyweb/backend/accounts/access_token_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index 11e6371257d..33dd5e285ad 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -151,7 +151,7 @@ def handle_password_token_grant(request: http.HttpRequest) -> http.HttpResponse: } return http.JsonResponse(error_response, status=400) - user: Specifyuser = authenticate(username=username, password=password) + user: Specifyuser | None = authenticate(username=username, password=password) if user is None or not has_collection_access(collection.id, user.id): error_response = { From 7e1955c6967801aa38af4e13dde2af39be86e2ea Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 11:21:50 -0500 Subject: [PATCH 29/39] fix: ensure enum value is used for OpenAPI spec --- specifyweb/backend/accounts/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 700aecc45ef..81cfe9787a6 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -638,7 +638,7 @@ def set_admin_status(request, userid): "properties": { "grant_type": { "type": "string", - "enum": [ACCESS_TOKEN_GRANT_TYPE.PASSWORD] + "enum": [ACCESS_TOKEN_GRANT_TYPE.PASSWORD.value] }, "username": { "type": "string" From 6250c4a1f1224a14a6468be6c28864801021e5aa Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 12:19:59 -0500 Subject: [PATCH 30/39] feat: follow OAuth2.0 spec for token revocation --- specifyweb/backend/accounts/views.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 81cfe9787a6..1b3b91006e7 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -765,20 +765,24 @@ def acquire_access_token(request: http.HttpRequest): "schema": { "type": "object", "properties": { - "access_token": { + "token": { "type": "string", - "description": "The JWT Access token to revoke" + "description": "The JWT token to revoke" }, + "token_type_hint": { + "type": "string", + "description": "A hint to the server about the type of token to revoke", + "enum": ['access_token'] + } }, - "required": ['access_token'], + "required": ['token'], 'additionalProperties': False } } } }, "responses": { - "204": {"description": "The access token was revoked"}, - "400": {"description": "The provided token is already invalid or could not be validated"}, + "204": {"description": "The access token was revoked or is already invalid"}, "403": {"description": "The user was not authenticated/logged-in when making a request to the endpoint"}, "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} } @@ -787,13 +791,22 @@ def acquire_access_token(request: http.HttpRequest): @require_POST @login_maybe_required def revoke_access_token(request): - encoded_token = request.POST.get("access_token") + """ + Where possible, this endpoint should generally follow the OAuth 2.0 Spec + for Token Revocation as defined by RFC 7009: + https://www.rfc-editor.org/info/rfc7009/ + + FEAT: When refresh tokens are implemented, allow their revocation + """ + encoded_token = request.POST.get("token") + if encoded_token is None: + return http.HttpResponse('', status=204) try: token = jwt.decode(encoded_token, settings.SECRET_KEY, options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) except jwt.exceptions.InvalidTokenError: - return http.HttpResponseBadRequest() + return http.HttpResponse('', status=204) revoke_token(token) From 775de4968c7c5316ea6e66594e930fc04b9930a9 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 13:24:16 -0500 Subject: [PATCH 31/39] feat: change parameter name for token expiration --- .../backend/accounts/access_token_utils.py | 16 ++++++++++++++-- specifyweb/backend/accounts/views.py | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index 33dd5e285ad..1cd17ddde31 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -62,7 +62,19 @@ def revoke_access_token(token: dict): redis_string.set(token_key, "true", time_to_live=blacklist_ttl) -def get_token_from_request(request) -> Literal[False] | None | dict: +def get_token_from_request(request: http.HttpRequest) -> Literal[False] | None | dict: + """ + Attempts to get an Access Token from a HTTP Request. + Currently, we only support using access tokens with an Authorization + Header. + + Particularly, clients should provide an access token like the following as + a Header: + Authorization: Bearer + + For more information, see Section 2.1 of RFC 6750: + https://www.rfc-editor.org/info/rfc6750/#section-2.1 + """ auth_header = request.headers.get("Authorization") if auth_header is None or not auth_header.startswith("Bearer "): return None @@ -105,7 +117,7 @@ def handle_password_token_grant(request: http.HttpRequest) -> http.HttpResponse: username = request.POST.get("username") password = request.POST.get("password") raw_collection_id = request.POST.get("collectionid") - raw_expires_in = request.POST.get("expires", DEFAULT_AUTH_LIFESPAN_SECONDS) + raw_expires_in = request.POST.get("expires_in", DEFAULT_AUTH_LIFESPAN_SECONDS) if None in (username, password, raw_collection_id): error_response = { diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 1b3b91006e7..d6ee4cf22d5 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -649,7 +649,7 @@ def set_admin_status(request, userid): "collectionid": { "type": "integer" }, - "expires": { + "expires_in": { "type": "integer", "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS} seconds if not specified", "default": DEFAULT_AUTH_LIFESPAN_SECONDS From 873195522d1b34984cb8fe83d04bf3b9d2f702e8 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 13:37:32 -0500 Subject: [PATCH 32/39] feat: define helper function to decode access tokens --- .../backend/accounts/access_token_utils.py | 27 ++++++++++++++----- specifyweb/backend/accounts/views.py | 10 +++---- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index 1cd17ddde31..e7a94994d35 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone, timedelta -from typing import Literal, Generator, cast +from typing import Literal, Generator, Any, cast from enum import StrEnum import jwt @@ -42,7 +42,7 @@ def generate_access_token(user, collection_id: int, expires_in: int = DEFAULT_AU return token -def revoke_access_token(token: dict): +def revoke_access_token(token: dict[str, Any]): """ Accepts and revokes a decoded JWT Auth Token. Specifically, stores the token in a "blacklist" in Redis for the remaining @@ -81,13 +81,28 @@ def get_token_from_request(request: http.HttpRequest) -> Literal[False] | None | encoded_token = auth_header.split(" ")[1] - try: - token = jwt.decode(encoded_token, settings.SECRET_KEY, - options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) - except jwt.exceptions.InvalidTokenError: + token = decode_access_token(encoded_token) + + if token is None: return False + return token +def decode_access_token(token: str) -> dict[str, Any] | None: + """ + Attempts to decode an Access Token. Returns the token if it's valid and + None otherwise + """ + try: + token = jwt.decode( + token, + settings.SECRET_KEY, + options=AUTH_JWT_DECODE_OPTIONS, + algorithms=AUTH_TOKEN_ALGORITHMS + ) + except jwt.exceptions.InvalidTokenError: + return None + return token def token_is_revoked(token: dict): token_identifier = token["jti"] diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index d6ee4cf22d5..cdcc20ca33f 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -37,9 +37,8 @@ from specifyweb.specify.auth.support_login import b64_url_to_bytes from specifyweb.backend.accounts.access_token_utils import ( DEFAULT_AUTH_LIFESPAN_SECONDS, - AUTH_JWT_DECODE_OPTIONS, - AUTH_TOKEN_ALGORITHMS, ACCESS_TOKEN_GRANT_TYPE, + decode_access_token, revoke_access_token as revoke_token, handle_password_token_grant) @@ -802,10 +801,9 @@ def revoke_access_token(request): if encoded_token is None: return http.HttpResponse('', status=204) - try: - token = jwt.decode(encoded_token, settings.SECRET_KEY, - options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) - except jwt.exceptions.InvalidTokenError: + token = decode_access_token(encoded_token) + + if token is None: return http.HttpResponse('', status=204) revoke_token(token) From fe8cb72a8b8e0a012989c0bd44ab37c7e5f26fdb Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 16:53:24 -0500 Subject: [PATCH 33/39] feat: add unit tests for Access Token authentiation and authorization --- .../accounts/tests/test_access_token.py | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 specifyweb/backend/accounts/tests/test_access_token.py diff --git a/specifyweb/backend/accounts/tests/test_access_token.py b/specifyweb/backend/accounts/tests/test_access_token.py new file mode 100644 index 00000000000..99196e95f2f --- /dev/null +++ b/specifyweb/backend/accounts/tests/test_access_token.py @@ -0,0 +1,368 @@ +import jwt +import uuid +import json +from unittest.mock import patch +from datetime import datetime, timezone, timedelta + +from django.test import Client + +from specifyweb.specify.models import Collection, Specifyuser, Agent +from specifyweb.backend.permissions.models import UserPolicy +from specifyweb.specify.api.serializers import toJson, _obj_to_data +from specifyweb.backend.accounts.access_token_utils import generate_access_token, decode_access_token, DEFAULT_AUTH_LIFESPAN_SECONDS, AUTH_TOKEN_ALGORITHMS +from specifyweb.specify.tests.test_api import ApiTests + + +class AccessTokenTests(ApiTests): + def setUp(self) -> None: + super().setUp() + self.password = "testuser" + self.specifyuser.save() + self.other_collection = Collection.objects.create( + catalognumformatname='test', + collectionname='OtherCollection', + isembeddedcollectingevent=False, + discipline=self.discipline, + ) + self.other_user = self._set_up_other_user() + + def get_auth_header(self, access_token: str): + return { + "Authorization": f"Bearer {access_token}" + } + + def acquire_access_token(self, client: Client, **data): + response = client.post("/accounts/token/", data=data) + return response.status_code, response.json() + + def _set_up_other_user(self) -> Specifyuser: + other_user = Specifyuser.objects.create( + isloggedin=False, + isloggedinreport=False, + name="otheruser", + password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C" # testuser + ) + + def user_policy(resource, action): + return UserPolicy( + collection=self.other_collection, + specifyuser=other_user, + resource=resource, + action=action + ) + + user_policies = [ + user_policy('/system/sp7/collection', 'access'), + user_policy('/table/collectionobject', 'read'), + user_policy('/table/collectionobject', 'create'), + user_policy('/table/collectionobject', 'update') + ] + + UserPolicy.objects.bulk_create(user_policies) + + Agent.objects.create( + agenttype=0, + firstname="John", + lastname="Doe", + division=self.division, + specifyuser=other_user, + ) + + return other_user + + +class AccessTokenApiTests(AccessTokenTests): + def test_acquire_access_token_returns_signed_token(self) -> None: + client = Client() + client.force_login(self.specifyuser) + status_code, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id, + expires_in=120 + ) + + self.assertEqual(status_code, 200) + self.assertEqual(response["expires_in"], 120) + claims = decode_access_token(response["access_token"]) + self.assertIsNotNone(claims) + self.assertEqual(claims["sub"], str(self.specifyuser.id)) + self.assertEqual(claims["username"], self.specifyuser.name) + self.assertEqual(claims["collection"], self.collection.id) + + def test_acquire_access_token_succeeds_when_user_is_not_logged_in(self) -> None: + client = Client() + + status_code, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id + ) + + self.assertEqual(status_code, 200) + self.assertIn("access_token", response) + self.assertEqual(response["expires_in"], DEFAULT_AUTH_LIFESPAN_SECONDS) + + claims = decode_access_token(response["access_token"]) + self.assertIsNotNone(claims) + self.assertEqual(claims["sub"], str(self.specifyuser.id)) + self.assertEqual(claims["username"], self.specifyuser.name) + self.assertEqual(claims["collection"], self.collection.id) + + def test_acquire_access_token_rejects_missing_or_unsupported_grant_type(self) -> None: + client = Client() + status_code, response = self.acquire_access_token(client) + self.assertEqual(status_code, 400) + self.assertEqual(response["error"], "invalid_request") + + status_code, response = self.acquire_access_token( + client, grant_type="unknown") + self.assertEqual(status_code, 400) + self.assertEqual(response["error"], "unsupported_grant_type") + + def test_acquire_access_token_rejects_invalid_password(self) -> None: + client = Client() + status_code, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password="incorrect", + collectionid=self.collection.id, + ) + + self.assertEqual(status_code, 401) + self.assertEqual(response["error"], "invalid_client") + + def test_acquire_access_token_rejects_invalid_collection_access(self): + client = Client() + status_code, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.collection.id, + ) + self.assertEqual(status_code, 401) + self.assertEqual(response["error"], "invalid_client") + + def test_revoke_access_token_revokes_valid_token(self) -> None: + client = Client() + client.force_login(self.specifyuser) + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id, + ) + access_token = response["access_token"] + + response = client.post( + "/accounts/token/revoke/", + data={"token": access_token} + ) + + self.assertEqual(response.status_code, 204) + + def test_revoke_access_token_rejects_invalid_token(self) -> None: + client = Client() + client.force_login(self.specifyuser) + with patch("specifyweb.backend.accounts.views.revoke_token") as revoke: + response = client.post( + "/accounts/token/revoke/", + data={"token": "invalid_token"} + ) + + self.assertEqual(response.status_code, 204) + revoke.assert_not_called() + + +class AccessTokenAuthorizationTests(AccessTokenTests): + def serialize_obj(self, obj): + return _obj_to_data(obj, lambda _obj: None) + + def test_authorization_with_access_token(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id + ) + access_token = response['access_token'] + + co_to_get = self.collectionobjects[0] + object_response = client.get( + f"/api/specify/collectionobject/{co_to_get.id}/", + headers=self.get_auth_header(access_token) + ) + self.assertEqual(object_response.status_code, 200) + response_content = object_response.json() + expected_content = self.serialize_obj(co_to_get) + self.assertEqual(json.dumps(response_content), + toJson(expected_content)) + + def test_authorization_with_unsafe_method(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id + ) + access_token = response['access_token'] + co_to_modify = self.collectionobjects[0] + serialized_co = self.serialize_obj(co_to_modify) + old_version = co_to_modify.version + new_text_1 = "foo" + self.assertNotEqual(co_to_modify.text1, new_text_1) + + serialized_co["text1"] = new_text_1 + + object_response = client.put( + f"/api/specify/collectionobject/{co_to_modify.id}/", + data=toJson(serialized_co), + content_type='application/json', + headers=self.get_auth_header(access_token) + ) + co_to_modify.refresh_from_db() + self.assertEqual(object_response.status_code, 200) + self.assertNotEqual(co_to_modify.version, old_version) + self.assertEqual(co_to_modify.text1, new_text_1) + + def test_invalid_token(self): + client = Client() + invalid_token = "foo" + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(invalid_token) + ) + self.assertEqual(object_response.status_code, 401) + self.assertTrue(object_response.has_header("WWW-Authenticate")) + + def test_revoking_collection_access_invalidates_access(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.other_collection.id + ) + access_token = response['access_token'] + + UserPolicy.objects.filter( + specifyuser=self.other_user, + collection=self.other_collection, + resource='/system/sp7/collection', + action='access' + ).delete() + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(access_token) + ) + + self.assertEqual(object_response.status_code, 403) + + def test_revoking_token_invalidates_access(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.other_collection.id + ) + old_access_token = response['access_token'] + + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.other_collection.id + ) + access_token = response['access_token'] + + revoke_response = client.post( + "/accounts/token/revoke/", + data={"token": old_access_token}, + headers=self.get_auth_header(access_token) + ) + self.assertEqual(revoke_response.status_code, 204) + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(old_access_token) + ) + self.assertEqual(object_response.status_code, 401) + + def test_token_can_revoke_self(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.other_collection.id + ) + access_token = response['access_token'] + + revoke_response = client.post( + "/accounts/token/revoke/", + data={"token": access_token}, + headers=self.get_auth_header(access_token) + ) + self.assertEqual(revoke_response.status_code, 204) + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(access_token) + ) + + self.assertEqual(object_response.status_code, 401) + + def test_access_tokens_expire(self): + client = Client() + + # We're going to manually create a token that expired 10 seconds ago + access_token = generate_access_token( + self.specifyuser, self.collection.id, -10) + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(access_token) + ) + + self.assertEqual(object_response.status_code, 401) + self.assertTrue(object_response.has_header("WWW-Authenticate")) + + def test_tokens_must_be_signed_by_secret_key(self): + client = Client() + # This is a valid Specify Access Token (JWT) payload + jwt_payload = { + "sub": str(self.specifyuser.id), + "username": self.specifyuser.name, + "collection": self.collection.id, + "jti": str(uuid.uuid4()), + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(seconds=DEFAULT_AUTH_LIFESPAN_SECONDS) + } + fake_secret_key = "foobarbaz" + access_token = jwt.encode( + jwt_payload, fake_secret_key, algorithm=AUTH_TOKEN_ALGORITHMS[0]) + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(access_token) + ) + + self.assertEqual(object_response.status_code, 401) + self.assertTrue(object_response.has_header("WWW-Authenticate")) From 4d20454c54d96c0da2a5e4245bdcbaba94ff6e94 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 17:05:26 -0500 Subject: [PATCH 34/39] chore: fix type errors --- specifyweb/backend/accounts/access_token_utils.py | 9 ++++++--- .../backend/accounts/tests/test_access_token.py | 15 +++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index e7a94994d35..0a216ebfc0d 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -88,14 +88,14 @@ def get_token_from_request(request: http.HttpRequest) -> Literal[False] | None | return token -def decode_access_token(token: str) -> dict[str, Any] | None: +def decode_access_token(encoded_token: str) -> dict[str, Any] | None: """ Attempts to decode an Access Token. Returns the token if it's valid and None otherwise """ try: token = jwt.decode( - token, + encoded_token, settings.SECRET_KEY, options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS @@ -178,7 +178,10 @@ def handle_password_token_grant(request: http.HttpRequest) -> http.HttpResponse: } return http.JsonResponse(error_response, status=400) - user: Specifyuser | None = authenticate(username=username, password=password) + untyped_user = authenticate(username=username, password=password) + # authenticate should always return a Specifyuser, as that's our only Auth + # User Model + user = cast(Specifyuser | None, untyped_user) if user is None or not has_collection_access(collection.id, user.id): error_response = { diff --git a/specifyweb/backend/accounts/tests/test_access_token.py b/specifyweb/backend/accounts/tests/test_access_token.py index 99196e95f2f..f1205f68b42 100644 --- a/specifyweb/backend/accounts/tests/test_access_token.py +++ b/specifyweb/backend/accounts/tests/test_access_token.py @@ -3,6 +3,7 @@ import json from unittest.mock import patch from datetime import datetime, timezone, timedelta +from typing import cast, Any from django.test import Client @@ -88,9 +89,10 @@ def test_acquire_access_token_returns_signed_token(self) -> None: self.assertEqual(response["expires_in"], 120) claims = decode_access_token(response["access_token"]) self.assertIsNotNone(claims) - self.assertEqual(claims["sub"], str(self.specifyuser.id)) - self.assertEqual(claims["username"], self.specifyuser.name) - self.assertEqual(claims["collection"], self.collection.id) + enforced_claims = cast(dict[str, Any], claims) + self.assertEqual(enforced_claims["sub"], str(self.specifyuser.id)) + self.assertEqual(enforced_claims["username"], self.specifyuser.name) + self.assertEqual(enforced_claims["collection"], self.collection.id) def test_acquire_access_token_succeeds_when_user_is_not_logged_in(self) -> None: client = Client() @@ -109,9 +111,10 @@ def test_acquire_access_token_succeeds_when_user_is_not_logged_in(self) -> None: claims = decode_access_token(response["access_token"]) self.assertIsNotNone(claims) - self.assertEqual(claims["sub"], str(self.specifyuser.id)) - self.assertEqual(claims["username"], self.specifyuser.name) - self.assertEqual(claims["collection"], self.collection.id) + enforced_claims = cast(dict[str, Any], claims) + self.assertEqual(enforced_claims["sub"], str(self.specifyuser.id)) + self.assertEqual(enforced_claims["username"], self.specifyuser.name) + self.assertEqual(enforced_claims["collection"], self.collection.id) def test_acquire_access_token_rejects_missing_or_unsupported_grant_type(self) -> None: client = Client() From 17070e2c8b47a89f79f45c7870c2922a01967544 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 17:19:16 -0500 Subject: [PATCH 35/39] feat: add token_type to endpoint --- specifyweb/backend/accounts/access_token_utils.py | 3 ++- specifyweb/backend/accounts/views.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index 0a216ebfc0d..af1beb5617e 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -197,6 +197,7 @@ def handle_password_token_grant(request: http.HttpRequest) -> http.HttpResponse: # and replay attacks in mind. Maybe just store them in Redis... response = { "access_token": token, - "expires_in": expires_in + "expires_in": expires_in, + "token_type": "Bearer" } return http.JsonResponse(response) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index cdcc20ca33f..d548671362a 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -676,9 +676,14 @@ def set_admin_status(request, userid): "expires_in": { "type": "integer", "description": "The number of seconds the access token is live for." + }, + "token_type": { + "type": "string", + "description": "The type of access token. Currently, only Bearer tokens are supported", + "enum": ["Bearer"] } }, - "required": ['access_token', 'expires_in'] + "required": ['access_token', 'expires_in', 'token_type'] } } } @@ -729,6 +734,9 @@ def set_admin_status(request, userid): @require_POST @csrf_exempt def acquire_access_token(request: http.HttpRequest): + """ + Obtain an access token that can be used for API authentication. + """ # FEAT: Currently we only accept x-www-form-urlencoded because that is # required by the OAuth 2.0 framework, see section 4.4.2 of RFC 6749: # https://www.rfc-editor.org/info/rfc6749/#section-4.4.2 @@ -790,6 +798,9 @@ def acquire_access_token(request: http.HttpRequest): @require_POST @login_maybe_required def revoke_access_token(request): + """ + Revoke/Invalidate a previously generated access token + """ """ Where possible, this endpoint should generally follow the OAuth 2.0 Spec for Token Revocation as defined by RFC 7009: From 05188651b4e75e0c66fddb461903458b1baefda4 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 18:22:36 -0500 Subject: [PATCH 36/39] feat: improve OpenAPI docs for access token endpoints --- .../backend/accounts/access_token_utils.py | 2 +- specifyweb/backend/accounts/views.py | 82 +++++++++++-------- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index af1beb5617e..ccb7b33e5d1 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -76,7 +76,7 @@ def get_token_from_request(request: http.HttpRequest) -> Literal[False] | None | https://www.rfc-editor.org/info/rfc6750/#section-2.1 """ auth_header = request.headers.get("Authorization") - if auth_header is None or not auth_header.startswith("Bearer "): + if auth_header is None or not auth_header.lower().startswith("bearer "): return None encoded_token = auth_header.split(" ")[1] diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index d548671362a..1611415a0c2 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -625,39 +625,47 @@ def set_admin_status(request, userid): 'post': { "requestBody": { "required": True, - "description": "Obtain an access token that can be used with the API. This endpoint follows OAuth 2.0 Framework defined in RFC 6749: https://www.rfc-editor.org/info/rfc6749/. Specify supports using bearer access tokens through an Authorization Bearer header as defined in RFC 6750: https://www.rfc-editor.org/info/rfc6750/", + # BUG: Currently Swagger UI does not cleanly render OneOf request + # body options. + # The user had to go into the schema to view descriptions and each + # option. Or I was setting them up wrong. + # Because we only support one grant type at the moment, I'm + # including the description up here for convenience + "description": "Authenticate with a user name, password, and collection id. The resulting access token has the same permissions as the authenticated user used to generate it and will be 'logged in' or scoped to the provided collection.", "content": { "application/x-www-form-urlencoded": { "schema": { - "oneOf": [ - # FEAT: Add the refresh_token grant type, to - # generate access tokens - { - "type": "object", - "properties": { - "grant_type": { - "type": "string", - "enum": [ACCESS_TOKEN_GRANT_TYPE.PASSWORD.value] - }, - "username": { - "type": "string" - }, - "password": { - "type": "string" - }, - "collectionid": { - "type": "integer" - }, - "expires_in": { - "type": "integer", - "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS} seconds if not specified", - "default": DEFAULT_AUTH_LIFESPAN_SECONDS - } - }, - "required": ['grant_type', 'username', 'password', 'collectionid'], - 'additionalProperties': False + # FEAT: Add the refresh_token grant type, to generate + # access tokens. + # We can use oneOf here to validate against different + # schemas when refresh tokens are supported + "type": "object", + "properties": { + "grant_type": { + "type": "string", + "description": "The type of information you are authenticating with. We currently only support the password grant type", + "enum": [ACCESS_TOKEN_GRANT_TYPE.PASSWORD.value] + }, + "username": { + "type": "string", + "description": "The user name of the Specify User" + }, + "password": { + "type": "string", + "description": "The password for the Specify User" + }, + "collectionid": { + "type": "integer", + "description": "The ID of the Collection to scope this access token to. /context/login/ can be used to get Collection IDs if needed" + }, + "expires_in": { + "type": "integer", + "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS} seconds if not specified", + "default": DEFAULT_AUTH_LIFESPAN_SECONDS } - ] + }, + "required": ['grant_type', 'username', 'password', 'collectionid'], + 'additionalProperties': False } } } @@ -735,7 +743,12 @@ def set_admin_status(request, userid): @csrf_exempt def acquire_access_token(request: http.HttpRequest): """ - Obtain an access token that can be used for API authentication. + Obtain an access token for use with the API. + + This endpoint follows the OAuth 2.0 Framework defined in RFC 6749: https://www.rfc-editor.org/info/rfc6749/. + Specify supports using bearer access tokens through an Authorization Bearer header as defined in RFC 6750: https://www.rfc-editor.org/info/rfc6750/. + + To use an access token, include it in future requests with an "Authorization: Bearer " header, replacing with the generated access token. """ # FEAT: Currently we only accept x-www-form-urlencoded because that is # required by the OAuth 2.0 framework, see section 4.4.2 of RFC 6749: @@ -766,7 +779,6 @@ def acquire_access_token(request: http.HttpRequest): 'post': { "requestBody": { "required": True, - "description": "Revoke a previously granted access token", "content": { "application/x-www-form-urlencoded": { "schema": { @@ -774,11 +786,11 @@ def acquire_access_token(request: http.HttpRequest): "properties": { "token": { "type": "string", - "description": "The JWT token to revoke" + "description": "The access token to revoke" }, "token_type_hint": { "type": "string", - "description": "A hint to the server about the type of token to revoke", + "description": "A hint to the server about the type of token to revoke. This is ignored at the moment, but may have an effect in the future.", "enum": ['access_token'] } }, @@ -799,7 +811,9 @@ def acquire_access_token(request: http.HttpRequest): @login_maybe_required def revoke_access_token(request): """ - Revoke/Invalidate a previously generated access token + Revoke/invalidate a previously generated access token. + You must be authenticated (e.g., from a Session or by Bearer token) to call this endpoint. + A bearer token that is used for authentication can be revoked. That is, a token can revoke itself. """ """ Where possible, this endpoint should generally follow the OAuth 2.0 Spec From 27f13e1a84cf4b0d542fb7a13900191c55cfb2fe Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 18:24:31 -0500 Subject: [PATCH 37/39] fix: clamp token blacklist ttl to greater than 1 second --- specifyweb/backend/accounts/access_token_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index ccb7b33e5d1..7c337311756 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -57,9 +57,11 @@ def revoke_access_token(token: dict[str, Any]): expires_at = token["exp"] current_time = int(datetime.now(timezone.utc).timestamp()) blacklist_ttl = expires_at - current_time + if blacklist_ttl < 1: + return redis_string = RedisString(RedisConnection()) token_key = f"revoked:{jti}" - redis_string.set(token_key, "true", time_to_live=blacklist_ttl) + redis_string.set(token_key, "true", time_to_live=max(blacklist_ttl, 1)) def get_token_from_request(request: http.HttpRequest) -> Literal[False] | None | dict: From 4a9eb933bcdbc6f281231f9e0b6076bca3462da7 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 18:38:34 -0500 Subject: [PATCH 38/39] fix: handle case when authorization header has extra whitespace --- specifyweb/backend/accounts/access_token_utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index 7c337311756..3209996418c 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -81,7 +81,13 @@ def get_token_from_request(request: http.HttpRequest) -> Literal[False] | None | if auth_header is None or not auth_header.lower().startswith("bearer "): return None - encoded_token = auth_header.split(" ")[1] + auth_fields = auth_header.split(" ") + # If there is more than one space in the Authorization header, consider it + # invalid + if len(auth_fields) != 2: + return False + + encoded_token = auth_fields[1] token = decode_access_token(encoded_token) From b0979670bffcd1e4604099c72cd3d4747e58593c Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 29 Oct 2026 18:40:02 -0500 Subject: [PATCH 39/39] fix: remove leading and trailing whitespace from authorization header --- specifyweb/backend/accounts/access_token_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py index 3209996418c..d7a190b386f 100644 --- a/specifyweb/backend/accounts/access_token_utils.py +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -81,7 +81,7 @@ def get_token_from_request(request: http.HttpRequest) -> Literal[False] | None | if auth_header is None or not auth_header.lower().startswith("bearer "): return None - auth_fields = auth_header.split(" ") + auth_fields = auth_header.strip().split(" ") # If there is more than one space in the Authorization header, consider it # invalid if len(auth_fields) != 2: