-
Notifications
You must be signed in to change notification settings - Fork 44
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
Open
melton-jason
wants to merge
26
commits into
main
Choose a base branch
from
issue-5163
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+361
−25
Open
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 c0dae4b
chore: reformat redis utils
melton-jason 3561173
Merge remote-tracking branch 'origin/main' into issue-5163
melton-jason e3e974d
Lint code with ESLint and Prettier
melton-jason 6b77369
fix: prevent users from generating tokens to collections they don't h…
melton-jason 901405f
Merge branch 'issue-5163' of github.com:specify/specify7 into issue-5163
melton-jason 8e4d577
Merge branch 'main' into issue-5163
melton-jason 56915b3
fix: properly scope collections when cookie is not set
melton-jason 22a81ce
chore: rename auth token to better fit spec, improve openapi schema
melton-jason 446ea8e
Merge branch 'issue-5163' of github.com:specify/specify7 into issue-5163
melton-jason 53dd4bb
feat: improve entropy of default secret key
melton-jason 0443cfc
Merge branch 'main' into issue-5163
melton-jason 930bdba
fix: handle case when user has no access to no collections
melton-jason d41937e
fix: invalid type in jsonschema openapi spec
melton-jason cddd865
feat: shortcircut middleware when token is present but invalid
melton-jason 8aa2a0c
feat: return proper WWW-Authenticate headers for invalid tokens
melton-jason 34b54a0
fix: correct return code in openapi jsonschema
melton-jason bb83979
feat: parse expiry time as int
melton-jason a1852e8
Merge branch 'main' into issue-5163
melton-jason 4babcbb
fix: generate key at build time rather than import time
melton-jason 7d75944
Merge branch 'main' into issue-5163
melton-jason a0a8af1
Lint code with ESLint and Prettier
melton-jason c82b53b
Merge branch 'main' into issue-5163
melton-jason 4c639aa
Merge branch 'main' into issue-5163
melton-jason af836ae
Merge branch 'main' into issue-5163
grantfitzsimmons 9f3c008
Merge branch 'main' into issue-5163
grantfitzsimmons File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.