From a5860185ad489c347b1a72ade0a8ba8b5423ab43 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 18 Mar 2026 11:43:42 -0500 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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__)