-
Notifications
You must be signed in to change notification settings - Fork 50
Allow authentication by JWT Bearer token #7826
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a586018
c0dae4b
3561173
e3e974d
6b77369
901405f
8e4d577
56915b3
22a81ce
446ea8e
53dd4bb
0443cfc
930bdba
d41937e
cddd865
8aa2a0c
34b54a0
bb83979
a1852e8
4babcbb
7d75944
a0a8af1
c82b53b
4c639aa
af836ae
9f3c008
5ec43ee
766ee18
d8241b1
3428849
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| 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]) | ||
|
Comment on lines
+29
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
python -m pip install --quiet 'PyJWT==2.12.1'
python - <<'PY'
import time
import jwt
now = int(time.time())
token = jwt.encode(
{"sub": 1, "iat": now, "exp": now + 60, "jti": "test"},
"test-key",
algorithm="HS256",
)
try:
jwt.decode(token, "test-key", algorithms=["HS256"])
except jwt.InvalidTokenError as error:
print(type(error).__name__, error)
PYRepository: specify/specify7 Length of output: 191 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- target file ---'
sed -n '1,110p' specifyweb/backend/accounts/access_token_utils.py
printf '%s\n' '--- middleware references ---'
rg -n -C 4 'jwt\.decode|AUTH_TOKEN|payload|["'"'"']sub["'"'"']' specifyweb/backend/accounts/middleware.py specifyweb/backend/accounts 2>/dev/null || true
printf '%s\n' '--- user model definition ---'
rg -n -C 5 'class Specifyuser|id\s*=|AutoField|BigAutoField' specifyweb -g '*.py' | head -200
printf '%s\n' '--- dependency declarations ---'
rg -n -i -C 2 'pyjwt|jwt' . -g 'requirements*.txt' -g 'pyproject.toml' -g 'setup.py' -g 'setup.cfg' -g 'Pipfile*' -g '*.lock' 2>/dev/null || trueRepository: specify/specify7 Length of output: 29084 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- middleware ---'
cat -n specifyweb/backend/accounts/middleware.py
printf '%s\n' '--- Specifyuser model candidates ---'
rg -n -l 'class Specifyuser' specifyweb -g '*.py' | while read -r file; do
printf '\n--- %s ---\n' "$file"
rg -n -C 12 'class Specifyuser' "$file"
done
printf '%s\n' '--- token generation and middleware tests/usages ---'
rg -n -C 5 'generate_access_token|get_token_from_request|request\.specify_user|check_permission_targets' specifyweb -g '*.py' | head -300Repository: specify/specify7 Length of output: 30094 🌐 Web query:
💡 Result: In PyJWT 2.10.0 and later versions, including 2.12.1, the validation logic for the Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- collection access and permission call path ---'
rg -n -C 12 'def has_collection_access|def check_permission_targets' specifyweb/backend -g '*.py'
printf '%s\n' '--- structural verifier ---'
python3 - <<'PY'
import ast
from pathlib import Path
access_path = Path("specifyweb/backend/accounts/access_token_utils.py")
middleware_path = Path("specifyweb/backend/accounts/middleware.py")
access = ast.parse(access_path.read_text())
middleware = ast.parse(middleware_path.read_text())
generate = next(
node for node in ast.walk(access)
if isinstance(node, ast.FunctionDef) and node.name == "generate_access_token"
)
sub_assignments = [
node for node in ast.walk(generate)
if isinstance(node, ast.Assign)
and isinstance(node.value, ast.Dict)
and any(
isinstance(key, ast.Constant) and key.value == "sub"
for key in node.value.keys
)
]
sub_value = None
for assignment in sub_assignments:
for key, value in zip(assignment.value.keys, assignment.value.values):
if isinstance(key, ast.Constant) and key.value == "sub":
sub_value = ast.dump(value)
print("generated_sub_expression:", sub_value)
decode_calls = [
node for node in ast.walk(access)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "decode"
]
print("decode_call_count:", len(decode_calls))
middleware_sub_assignments = [
node for node in ast.walk(middleware)
if isinstance(node, ast.Assign)
and any(isinstance(target, ast.Name) and target.id == "user_id"
for target in node.targets)
]
print("middleware_user_id_expression:", [
ast.dump(node.value) for node in middleware_sub_assignments
])
PYRepository: specify/specify7 Length of output: 4248 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- permission query implementation ---'
rg -n -C 20 'def query_pt|def query\(' specifyweb/backend/permissions -g '*.py'
printf '%s\n' '--- model/query consumers of token sub ---'
rg -n -C 4 'token\[[\"'\"']sub[\"'\"']\]|user_id\s*=\s*token' specifyweb -g '*.py'Repository: specify/specify7 Length of output: 7726 Serialize the JWT PyJWT 2.12.1 rejects a non-string 🤖 Prompt for AI Agents |
||
| 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}") | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,64 @@ | ||||||||||
| 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.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) \ | ||||||||||
| .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\"' | ||||||||||
|
Comment on lines
+33
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Prefix the authentication challenge with The current - 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"'📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| return response | ||||||||||
|
|
||||||||||
| user_id = token["sub"] | ||||||||||
| collection_id = 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 | ||||||||||
| # 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_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) | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 | ||||||||||
|
|
||||||||||
| # REFACTOR: Replace these with RedisConnection adapters | ||||||||||
|
|
||||||||||
|
|
@@ -36,3 +36,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) | ||||||||||
|
Comment on lines
+41
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Format revocation keys before checking Redis.
Proposed fix def key_exists(key: str) -> bool:
- return _key_exists(key)
+ return _key_exists(format_key(key))📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Do not bake the JWT signing key into the image.
Line 235 writes the fallback key into an image layer. Anyone who can inspect or obtain the image can recover the signing key. All deployments from that image also share the key.
Require
SECRET_KEYfrom the runtime secret store. If automatic generation is required, generate and persist it in an access-controlled runtime secret shared by all application processes.🤖 Prompt for AI Agents