-
Notifications
You must be signed in to change notification settings - Fork 51
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 23 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,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}") |
| 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\"' | ||||||||||
|
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 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) | ||||||||||
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