Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a586018
feat: allow authentication via JWT Bearer Tokens
melton-jason Mar 18, 2026
c0dae4b
chore: reformat redis utils
melton-jason Mar 18, 2026
3561173
Merge remote-tracking branch 'origin/main' into issue-5163
melton-jason Mar 18, 2026
e3e974d
Lint code with ESLint and Prettier
melton-jason Mar 18, 2026
6b77369
fix: prevent users from generating tokens to collections they don't h…
melton-jason Mar 18, 2026
901405f
Merge branch 'issue-5163' of github.com:specify/specify7 into issue-5163
melton-jason Mar 18, 2026
8e4d577
Merge branch 'main' into issue-5163
melton-jason Mar 18, 2026
56915b3
fix: properly scope collections when cookie is not set
melton-jason Mar 19, 2026
22a81ce
chore: rename auth token to better fit spec, improve openapi schema
melton-jason Mar 19, 2026
446ea8e
Merge branch 'issue-5163' of github.com:specify/specify7 into issue-5163
melton-jason Mar 19, 2026
53dd4bb
feat: improve entropy of default secret key
melton-jason Mar 19, 2026
0443cfc
Merge branch 'main' into issue-5163
melton-jason Mar 19, 2026
930bdba
fix: handle case when user has no access to no collections
melton-jason Mar 19, 2026
d41937e
fix: invalid type in jsonschema openapi spec
melton-jason Mar 19, 2026
cddd865
feat: shortcircut middleware when token is present but invalid
melton-jason Mar 19, 2026
8aa2a0c
feat: return proper WWW-Authenticate headers for invalid tokens
melton-jason Mar 19, 2026
34b54a0
fix: correct return code in openapi jsonschema
melton-jason Mar 19, 2026
bb83979
feat: parse expiry time as int
melton-jason Mar 19, 2026
a1852e8
Merge branch 'main' into issue-5163
melton-jason Mar 20, 2026
4babcbb
fix: generate key at build time rather than import time
melton-jason Mar 26, 2026
7d75944
Merge branch 'main' into issue-5163
melton-jason Mar 26, 2026
a0a8af1
Lint code with ESLint and Prettier
melton-jason Mar 26, 2026
c82b53b
Merge branch 'main' into issue-5163
melton-jason Apr 3, 2026
4c639aa
Merge branch 'main' into issue-5163
melton-jason Apr 10, 2026
af836ae
Merge branch 'main' into issue-5163
grantfitzsimmons Apr 14, 2026
9f3c008
Merge branch 'main' into issue-5163
grantfitzsimmons Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,18 @@ 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
DEFAULT_KEY="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 50)"
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
else:
new_key = CURRENT_KEY

SECRET_KEY=new_key
EOF

ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
Expand Down
72 changes: 72 additions & 0 deletions specifyweb/backend/accounts/auth_token_utils.py
Original file line number Diff line number Diff line change
@@ -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_access_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_access_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] | None | dict:
auth_header = request.headers.get("Authorization")
if auth_header is None or not auth_header.startswith("Bearer "):
return 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:
return False
return token


def token_is_revoked(token: dict):
token_identifier = token["jti"]
return key_exists(f"revoked:{token_identifier}")
58 changes: 58 additions & 0 deletions specifyweb/backend/accounts/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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
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:
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)
# 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):
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"]

# 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))
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)
3 changes: 3 additions & 0 deletions specifyweb/backend/accounts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
# OpenId Connect callback endpoint:
path('oic_callback/', views.oic_callback),

path('token/', views.acquire_access_token),
path('token/revoke/', views.revoke_access_token),

path(
'logout/',
skip_collection_access_check(auth_views.LogoutView.as_view(next_page='/accounts/login/'))
Expand Down
154 changes: 149 additions & 5 deletions specifyweb/backend/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -27,15 +27,17 @@
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
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_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__)
Expand Down Expand Up @@ -293,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


Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -580,3 +580,147 @@ 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 access 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": {
"200": {
"description": "The access token was successfully generated",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"expires_in": {
"type": "integer",
"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"}
}
},
})
@require_POST
@csrf_exempt
def acquire_access_token(request):
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)

Comment thread
acwhite211 marked this conversation as resolved.
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")


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)

# 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
}
return http.JsonResponse(response)


@openapi(schema={
'post': {
"requestBody": {
"required": True,
"description": "Revoke a previously granted access token",
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": {
"access_token": {
"type": "string",
"description": "The JWT Access token to revoke"
},
},
"required": ['access_token'],
'additionalProperties': False
}
}
}
},
"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"},
"405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"}
}
},
})
@require_POST
@login_maybe_required
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)
except jwt.exceptions.InvalidTokenError:
return http.HttpResponseBadRequest()

revoke_token(token)

return http.HttpResponse('', status=204)
Loading
Loading