Repository navigation
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
54
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.
+953
−26
Open
Changes from all commits
Commits
Show all changes
54 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 5ec43ee
Merge branch 'main' into issue-5163
melton-jason 766ee18
chore: improve documentation and formatting
melton-jason d8241b1
Merge branch 'main' into issue-5163
melton-jason 3428849
Merge remote-tracking branch 'origin/main' into issue-5163
melton-jason 9b77170
Merge remote-tracking branch 'origin/main' into issue-5163
melton-jason e901516
fix: ensure types when serializing and deserailizing access tokens
melton-jason 9e99ec5
fix: align header when token is invalid to RFC 6750
melton-jason d642c09
chore: add comment linking to RFC for WWW-Authenticate header
melton-jason bf1bd4f
chore: more OAuth 2.0 information in comments
melton-jason 711ce71
feat: more closely follow OAuth2.0 spec and allow access token extension
melton-jason 805c8c9
fix: generate default secret key at runtime if default or not specified
melton-jason 8aac18a
fix: openapi schema errors and undocumented 404 for set_user_agents
melton-jason 5465502
chore: add type to decode options
melton-jason bb7ff0c
fix: user key from environment for image
melton-jason 97e0a13
refactor: fix type errors in access_token_utils
melton-jason 0950bdb
feat: show available grant types when type is missing or unsupported
melton-jason 1bb3989
chore: fix union type when authenticating user
melton-jason 7e1955c
fix: ensure enum value is used for OpenAPI spec
melton-jason 6250c4a
feat: follow OAuth2.0 spec for token revocation
melton-jason 775de49
feat: change parameter name for token expiration
melton-jason 8731955
feat: define helper function to decode access tokens
melton-jason fe8cb72
feat: add unit tests for Access Token authentiation and authorization
melton-jason 4d20454
chore: fix type errors
melton-jason 17070e2
feat: add token_type to endpoint
melton-jason 0518865
feat: improve OpenAPI docs for access token endpoints
melton-jason 27f13e1
fix: clamp token blacklist ttl to greater than 1 second
melton-jason 4a9eb93
fix: handle case when authorization header has extra whitespace
melton-jason b097967
fix: remove leading and trailing whitespace from authorization header
melton-jason 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| import uuid | ||
|
|
||
| from datetime import datetime, timezone, timedelta | ||
| from typing import Literal, Generator, Any, cast | ||
| from enum import StrEnum | ||
|
|
||
| import jwt | ||
| from django import http | ||
| from django.contrib.auth import authenticate | ||
| from django.conf import settings | ||
|
|
||
| from specifyweb.specify.models import Collection, Specifyuser | ||
| from specifyweb.backend.cache.redis.connect import RedisConnection, RedisString | ||
| from specifyweb.backend.context.views import has_collection_access | ||
|
|
||
| DEFAULT_AUTH_LIFESPAN_SECONDS = 1800 | ||
|
|
||
| # See https://pyjwt.readthedocs.io/en/latest/api.html#jwt.decode | ||
| AUTH_JWT_DECODE_OPTIONS: jwt.types.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": str(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[str, Any]): | ||
| """ | ||
| 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 | ||
| if blacklist_ttl < 1: | ||
| return | ||
| redis_string = RedisString(RedisConnection()) | ||
| token_key = f"revoked:{jti}" | ||
| redis_string.set(token_key, "true", time_to_live=max(blacklist_ttl, 1)) | ||
|
|
||
|
|
||
| def get_token_from_request(request: http.HttpRequest) -> Literal[False] | None | dict: | ||
| """ | ||
| Attempts to get an Access Token from a HTTP Request. | ||
| Currently, we only support using access tokens with an Authorization | ||
| Header. | ||
|
|
||
| Particularly, clients should provide an access token like the following as | ||
| a Header: | ||
| Authorization: Bearer <token> | ||
|
|
||
| For more information, see Section 2.1 of RFC 6750: | ||
| https://www.rfc-editor.org/info/rfc6750/#section-2.1 | ||
| """ | ||
| auth_header = request.headers.get("Authorization") | ||
| if auth_header is None or not auth_header.lower().startswith("bearer "): | ||
| return None | ||
|
|
||
| auth_fields = auth_header.strip().split(" ") | ||
| # If there is more than one space in the Authorization header, consider it | ||
| # invalid | ||
| if len(auth_fields) != 2: | ||
| return False | ||
|
|
||
| encoded_token = auth_fields[1] | ||
|
|
||
| token = decode_access_token(encoded_token) | ||
|
|
||
| if token is None: | ||
| return False | ||
|
|
||
| return token | ||
|
|
||
| def decode_access_token(encoded_token: str) -> dict[str, Any] | None: | ||
| """ | ||
| Attempts to decode an Access Token. Returns the token if it's valid and | ||
| None otherwise | ||
| """ | ||
| try: | ||
| token = jwt.decode( | ||
| encoded_token, | ||
| settings.SECRET_KEY, | ||
| options=AUTH_JWT_DECODE_OPTIONS, | ||
| algorithms=AUTH_TOKEN_ALGORITHMS | ||
| ) | ||
| except jwt.exceptions.InvalidTokenError: | ||
| return None | ||
| return token | ||
|
|
||
| def token_is_revoked(token: dict): | ||
| token_identifier = token["jti"] | ||
| redis_string = RedisString(RedisConnection()) | ||
| token_key = f"revoked:{token_identifier}" | ||
| return redis_string.exists(token_key) | ||
|
|
||
| class ACCESS_TOKEN_GRANT_TYPE(StrEnum): | ||
| # The client provides the server with a username, password, and collection | ||
| # id | ||
| PASSWORD = "password" | ||
|
|
||
| @classmethod | ||
| def types(cls) -> Generator[str, None, None]: | ||
| return (item.value for item in cls) | ||
|
|
||
|
|
||
| def handle_password_token_grant(request: http.HttpRequest) -> http.HttpResponse: | ||
| """ | ||
| The client indiciated they want to authenticate using username and password. | ||
| They should have also provided a collection id to scope the token to. | ||
|
|
||
| See Section 4.3 of RFC 6749 for more information about this specific grant | ||
| type according to the OAuth 2.0 Framework: | ||
| https://www.rfc-editor.org/info/rfc6749/#section-4.3 | ||
| """ | ||
| username = request.POST.get("username") | ||
| password = request.POST.get("password") | ||
| raw_collection_id = request.POST.get("collectionid") | ||
| raw_expires_in = request.POST.get("expires_in", DEFAULT_AUTH_LIFESPAN_SECONDS) | ||
|
|
||
| if None in (username, password, raw_collection_id): | ||
| error_response = { | ||
| "error": "invalid_request", | ||
| "error_description": "username, password, and collectionid are required" | ||
| } | ||
| return http.JsonResponse(error_response, status=400) | ||
|
|
||
| try: | ||
| expires_in = int(raw_expires_in) | ||
| if expires_in <= 0: | ||
| error_response = { | ||
| "error": "invalid_request", | ||
| "error_description": "Token expiry time must be greater than 0" | ||
| } | ||
| return http.JsonResponse(error_response, status=400) | ||
| except ValueError: | ||
| error_response = { | ||
| "error": "invalid_request", | ||
| "error_description": "Expiry time could not be parsed as integer" | ||
| } | ||
| return http.JsonResponse(error_response, status=400) | ||
|
|
||
| try: | ||
| # We know that the raw_collection_id can not be NONE here as that was | ||
| # caught by the first check for username, password, and collectionid | ||
| # In the worst case scenario where the above check is changed and this | ||
| # can be None, this will raise TypeError | ||
| collection_id = int(cast(str, raw_collection_id)) | ||
| except ValueError: | ||
| error_response = { | ||
| "error": "invalid_request", | ||
| "error_description": "Collection ID could not be parsed as integer" | ||
| } | ||
| return http.JsonResponse(error_response, status=400) | ||
|
|
||
| try: | ||
| collection = Collection.objects.get(id=collection_id) | ||
| except Collection.DoesNotExist: | ||
| error_response = { | ||
| "error": "invalid_request", | ||
| "error_description": f"Collection {collection_id} does not exist" | ||
| } | ||
| return http.JsonResponse(error_response, status=400) | ||
|
|
||
| untyped_user = authenticate(username=username, password=password) | ||
| # authenticate should always return a Specifyuser, as that's our only Auth | ||
| # User Model | ||
| user = cast(Specifyuser | None, untyped_user) | ||
|
|
||
| if user is None or not has_collection_access(collection.id, user.id): | ||
| error_response = { | ||
| "error": "invalid_client", | ||
| "error_description": "Credentials were incorrect or user does not have access to the Collection" | ||
| } | ||
| return http.JsonResponse(error_response, status=401) | ||
|
|
||
| token = generate_access_token(user, collection.id, expires_in=expires_in) | ||
|
|
||
| # TODO: 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, | ||
| "token_type": "Bearer" | ||
| } | ||
| return http.JsonResponse(response) | ||
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,77 @@ | ||
| 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: | ||
| """ | ||
| This middleware is in charge of handling requests that contain a JWT | ||
| Authorization Bearer Token following the Auth 2.0 framework. | ||
| See RFCs 6749 and 6750 for the Auth 2.0 spec: | ||
| https://www.rfc-editor.org/info/rfc6749/ | ||
| https://www.rfc-editor.org/info/rfc6750/ | ||
| """ | ||
| 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) | ||
| # This header is formatted per OAuth 2.0, see section 5.2 of RFC | ||
| # 6749 and section 3 of RFC 6750 | ||
| # See https://www.rfc-editor.org/info/rfc6749/#section-5.2 and | ||
| # https://www.rfc-editor.org/info/rfc6750/#section-3 | ||
| response["WWW-Authenticate"] = 'Bearer error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"' | ||
| return response | ||
|
|
||
| # REFACTOR: explicitly handle cases where these values can't be cast as | ||
| # an integer? | ||
| user_id = int(token["sub"]) | ||
| collection_id = int(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) |
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.