diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py new file mode 100644 index 00000000000..d7a190b386f --- /dev/null +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -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 + + 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) diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py new file mode 100644 index 00000000000..86463acd0f7 --- /dev/null +++ b/specifyweb/backend/accounts/middleware.py @@ -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) diff --git a/specifyweb/backend/accounts/tests/test_access_token.py b/specifyweb/backend/accounts/tests/test_access_token.py new file mode 100644 index 00000000000..f1205f68b42 --- /dev/null +++ b/specifyweb/backend/accounts/tests/test_access_token.py @@ -0,0 +1,371 @@ +import jwt +import uuid +import json +from unittest.mock import patch +from datetime import datetime, timezone, timedelta +from typing import cast, Any + +from django.test import Client + +from specifyweb.specify.models import Collection, Specifyuser, Agent +from specifyweb.backend.permissions.models import UserPolicy +from specifyweb.specify.api.serializers import toJson, _obj_to_data +from specifyweb.backend.accounts.access_token_utils import generate_access_token, decode_access_token, DEFAULT_AUTH_LIFESPAN_SECONDS, AUTH_TOKEN_ALGORITHMS +from specifyweb.specify.tests.test_api import ApiTests + + +class AccessTokenTests(ApiTests): + def setUp(self) -> None: + super().setUp() + self.password = "testuser" + self.specifyuser.save() + self.other_collection = Collection.objects.create( + catalognumformatname='test', + collectionname='OtherCollection', + isembeddedcollectingevent=False, + discipline=self.discipline, + ) + self.other_user = self._set_up_other_user() + + def get_auth_header(self, access_token: str): + return { + "Authorization": f"Bearer {access_token}" + } + + def acquire_access_token(self, client: Client, **data): + response = client.post("/accounts/token/", data=data) + return response.status_code, response.json() + + def _set_up_other_user(self) -> Specifyuser: + other_user = Specifyuser.objects.create( + isloggedin=False, + isloggedinreport=False, + name="otheruser", + password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C" # testuser + ) + + def user_policy(resource, action): + return UserPolicy( + collection=self.other_collection, + specifyuser=other_user, + resource=resource, + action=action + ) + + user_policies = [ + user_policy('/system/sp7/collection', 'access'), + user_policy('/table/collectionobject', 'read'), + user_policy('/table/collectionobject', 'create'), + user_policy('/table/collectionobject', 'update') + ] + + UserPolicy.objects.bulk_create(user_policies) + + Agent.objects.create( + agenttype=0, + firstname="John", + lastname="Doe", + division=self.division, + specifyuser=other_user, + ) + + return other_user + + +class AccessTokenApiTests(AccessTokenTests): + def test_acquire_access_token_returns_signed_token(self) -> None: + client = Client() + client.force_login(self.specifyuser) + status_code, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id, + expires_in=120 + ) + + self.assertEqual(status_code, 200) + self.assertEqual(response["expires_in"], 120) + claims = decode_access_token(response["access_token"]) + self.assertIsNotNone(claims) + enforced_claims = cast(dict[str, Any], claims) + self.assertEqual(enforced_claims["sub"], str(self.specifyuser.id)) + self.assertEqual(enforced_claims["username"], self.specifyuser.name) + self.assertEqual(enforced_claims["collection"], self.collection.id) + + def test_acquire_access_token_succeeds_when_user_is_not_logged_in(self) -> None: + client = Client() + + status_code, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id + ) + + self.assertEqual(status_code, 200) + self.assertIn("access_token", response) + self.assertEqual(response["expires_in"], DEFAULT_AUTH_LIFESPAN_SECONDS) + + claims = decode_access_token(response["access_token"]) + self.assertIsNotNone(claims) + enforced_claims = cast(dict[str, Any], claims) + self.assertEqual(enforced_claims["sub"], str(self.specifyuser.id)) + self.assertEqual(enforced_claims["username"], self.specifyuser.name) + self.assertEqual(enforced_claims["collection"], self.collection.id) + + def test_acquire_access_token_rejects_missing_or_unsupported_grant_type(self) -> None: + client = Client() + status_code, response = self.acquire_access_token(client) + self.assertEqual(status_code, 400) + self.assertEqual(response["error"], "invalid_request") + + status_code, response = self.acquire_access_token( + client, grant_type="unknown") + self.assertEqual(status_code, 400) + self.assertEqual(response["error"], "unsupported_grant_type") + + def test_acquire_access_token_rejects_invalid_password(self) -> None: + client = Client() + status_code, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password="incorrect", + collectionid=self.collection.id, + ) + + self.assertEqual(status_code, 401) + self.assertEqual(response["error"], "invalid_client") + + def test_acquire_access_token_rejects_invalid_collection_access(self): + client = Client() + status_code, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.collection.id, + ) + self.assertEqual(status_code, 401) + self.assertEqual(response["error"], "invalid_client") + + def test_revoke_access_token_revokes_valid_token(self) -> None: + client = Client() + client.force_login(self.specifyuser) + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id, + ) + access_token = response["access_token"] + + response = client.post( + "/accounts/token/revoke/", + data={"token": access_token} + ) + + self.assertEqual(response.status_code, 204) + + def test_revoke_access_token_rejects_invalid_token(self) -> None: + client = Client() + client.force_login(self.specifyuser) + with patch("specifyweb.backend.accounts.views.revoke_token") as revoke: + response = client.post( + "/accounts/token/revoke/", + data={"token": "invalid_token"} + ) + + self.assertEqual(response.status_code, 204) + revoke.assert_not_called() + + +class AccessTokenAuthorizationTests(AccessTokenTests): + def serialize_obj(self, obj): + return _obj_to_data(obj, lambda _obj: None) + + def test_authorization_with_access_token(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id + ) + access_token = response['access_token'] + + co_to_get = self.collectionobjects[0] + object_response = client.get( + f"/api/specify/collectionobject/{co_to_get.id}/", + headers=self.get_auth_header(access_token) + ) + self.assertEqual(object_response.status_code, 200) + response_content = object_response.json() + expected_content = self.serialize_obj(co_to_get) + self.assertEqual(json.dumps(response_content), + toJson(expected_content)) + + def test_authorization_with_unsafe_method(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.specifyuser.name, + password=self.password, + collectionid=self.collection.id + ) + access_token = response['access_token'] + co_to_modify = self.collectionobjects[0] + serialized_co = self.serialize_obj(co_to_modify) + old_version = co_to_modify.version + new_text_1 = "foo" + self.assertNotEqual(co_to_modify.text1, new_text_1) + + serialized_co["text1"] = new_text_1 + + object_response = client.put( + f"/api/specify/collectionobject/{co_to_modify.id}/", + data=toJson(serialized_co), + content_type='application/json', + headers=self.get_auth_header(access_token) + ) + co_to_modify.refresh_from_db() + self.assertEqual(object_response.status_code, 200) + self.assertNotEqual(co_to_modify.version, old_version) + self.assertEqual(co_to_modify.text1, new_text_1) + + def test_invalid_token(self): + client = Client() + invalid_token = "foo" + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(invalid_token) + ) + self.assertEqual(object_response.status_code, 401) + self.assertTrue(object_response.has_header("WWW-Authenticate")) + + def test_revoking_collection_access_invalidates_access(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.other_collection.id + ) + access_token = response['access_token'] + + UserPolicy.objects.filter( + specifyuser=self.other_user, + collection=self.other_collection, + resource='/system/sp7/collection', + action='access' + ).delete() + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(access_token) + ) + + self.assertEqual(object_response.status_code, 403) + + def test_revoking_token_invalidates_access(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.other_collection.id + ) + old_access_token = response['access_token'] + + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.other_collection.id + ) + access_token = response['access_token'] + + revoke_response = client.post( + "/accounts/token/revoke/", + data={"token": old_access_token}, + headers=self.get_auth_header(access_token) + ) + self.assertEqual(revoke_response.status_code, 204) + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(old_access_token) + ) + self.assertEqual(object_response.status_code, 401) + + def test_token_can_revoke_self(self): + client = Client() + _, response = self.acquire_access_token( + client, + grant_type="password", + username=self.other_user.name, + password=self.password, + collectionid=self.other_collection.id + ) + access_token = response['access_token'] + + revoke_response = client.post( + "/accounts/token/revoke/", + data={"token": access_token}, + headers=self.get_auth_header(access_token) + ) + self.assertEqual(revoke_response.status_code, 204) + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(access_token) + ) + + self.assertEqual(object_response.status_code, 401) + + def test_access_tokens_expire(self): + client = Client() + + # We're going to manually create a token that expired 10 seconds ago + access_token = generate_access_token( + self.specifyuser, self.collection.id, -10) + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(access_token) + ) + + self.assertEqual(object_response.status_code, 401) + self.assertTrue(object_response.has_header("WWW-Authenticate")) + + def test_tokens_must_be_signed_by_secret_key(self): + client = Client() + # This is a valid Specify Access Token (JWT) payload + jwt_payload = { + "sub": str(self.specifyuser.id), + "username": self.specifyuser.name, + "collection": self.collection.id, + "jti": str(uuid.uuid4()), + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(seconds=DEFAULT_AUTH_LIFESPAN_SECONDS) + } + fake_secret_key = "foobarbaz" + access_token = jwt.encode( + jwt_payload, fake_secret_key, algorithm=AUTH_TOKEN_ALGORITHMS[0]) + + object_response = client.get( + f"/api/specify/collectionobject/", + headers=self.get_auth_header(access_token) + ) + + self.assertEqual(object_response.status_code, 401) + self.assertTrue(object_response.has_header("WWW-Authenticate")) diff --git a/specifyweb/backend/accounts/urls.py b/specifyweb/backend/accounts/urls.py index ab4ad1f5955..38b1905bbf9 100644 --- a/specifyweb/backend/accounts/urls.py +++ b/specifyweb/backend/accounts/urls.py @@ -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/')) diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index 8098e56f371..1611415a0c2 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -6,20 +6,19 @@ 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.models import AbstractBaseUser +from django.contrib.auth import login, authenticate from django.db import connection, transaction from django.db.models import Max -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 from django.template.response import TemplateResponse from django.utils import crypto from django.utils import timezone 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 django.views.decorators.http import require_POST from typing import cast @@ -29,13 +28,19 @@ 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, UserPresence -from specifyweb.specify.models import Specifyuser +from specifyweb.specify.models import Specifyuser, Agent, Collection from specifyweb.specify.auth.support_login import b64_url_to_bytes +from specifyweb.backend.accounts.access_token_utils import ( + DEFAULT_AUTH_LIFESPAN_SECONDS, + ACCESS_TOKEN_GRANT_TYPE, + decode_access_token, + revoke_access_token as revoke_token, + handle_password_token_grant) logger = logging.getLogger(__name__) @@ -323,7 +328,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.specify.api.serializers import obj_to_data, toJson from specifyweb.backend.setup_tool.api import is_collection_available @@ -386,7 +390,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"]) @@ -432,7 +435,7 @@ def set_password(request, userid): """ check_permission_targets(None, request.specify_user.id, [ SetPasswordPT.update]) - user = spmodels.Specifyuser.objects.get(pk=userid) + user = Specifyuser.objects.get(pk=userid) user.set_password(request.POST['password']) user.save() return http.HttpResponse('', status=204) @@ -510,6 +513,9 @@ def set_password(request, userid): } } } + }, + "404": { + "description": "No SpecifyUser with the ID exists" } } }, @@ -518,24 +524,25 @@ def set_password(request, userid): @require_POST def set_user_agents(request, userid: int): "Sets the agents to represent the user in different disciplines." - user = spmodels.Specifyuser.objects.get(pk=userid) + # BUG: Handle returning 400 code when userid is not an int + user = get_object_or_404(Specifyuser, pk=userid) new_agentids = json.loads(request.body) cursor = connection.cursor() with transaction.atomic(): # clear user's existing agents - spmodels.Agent.objects.filter( - specifyuser_id=userid).update(specifyuser_id=None) + Agent.objects.filter( + specifyuser_id=user.pk).update(specifyuser_id=None) # check if any of the agents to be assigned are used by other users - in_use = spmodels.Agent.objects.select_for_update().filter( + in_use = Agent.objects.select_for_update().filter( pk__in=new_agentids, specifyuser_id__isnull=False) if in_use: raise AgentInUseException([a.id for a in in_use]) # assign the new agents - spmodels.Agent.objects.filter( - pk__in=new_agentids).update(specifyuser_id=userid) + Agent.objects.filter( + pk__in=new_agentids).update(specifyuser_id=user.pk) # check for multiple agents assigned to the user # NOTE: This is too aggressive (and inefficient) of a test. @@ -545,7 +552,7 @@ def set_user_agents(request, userid: int): """select divisionid, a1.agentid, a2.agentid from agent a1 join agent a2 using (specifyuserid, divisionid) where a1.agentid < a2.agentid and specifyuserid = %s - """, [userid] + """, [user.pk] ) multiple = [ @@ -556,15 +563,15 @@ def set_user_agents(request, userid: int): raise MultipleAgentsException(multiple) # get the list of collections the agents belong to. - collections = spmodels.Collection.objects.filter( - discipline__division__members__specifyuser_id=userid).values_list('id', flat=True) + collections = Collection.objects.filter( + discipline__division__members__specifyuser_id=user.pk).values_list('id', flat=True) # check permissions for setting user agents in those collections. for collectionid in collections: check_permission_targets(collectionid, request.specify_user.id, [ SetUserAgentsPT.update]) - check_collection_access_against_agents(userid) + check_collection_access_against_agents(user.pk) return http.HttpResponse('', status=204) @@ -605,10 +612,225 @@ def set_admin_status(request, userid): """ check_permission_targets( None, request.specify_user.id, [Sp6AdminPT.update]) - user = spmodels.Specifyuser.objects.get(pk=userid) + user = Specifyuser.objects.get(pk=userid) if request.POST['admin_status'] == 'true': user.set_admin() return http.HttpResponse('true', content_type='text/plain') else: user.clear_admin() return http.HttpResponse('false', content_type='text/plain') + + +@openapi(schema={ + 'post': { + "requestBody": { + "required": True, + # BUG: Currently Swagger UI does not cleanly render OneOf request + # body options. + # The user had to go into the schema to view descriptions and each + # option. Or I was setting them up wrong. + # Because we only support one grant type at the moment, I'm + # including the description up here for convenience + "description": "Authenticate with a user name, password, and collection id. The resulting access token has the same permissions as the authenticated user used to generate it and will be 'logged in' or scoped to the provided collection.", + "content": { + "application/x-www-form-urlencoded": { + "schema": { + # FEAT: Add the refresh_token grant type, to generate + # access tokens. + # We can use oneOf here to validate against different + # schemas when refresh tokens are supported + "type": "object", + "properties": { + "grant_type": { + "type": "string", + "description": "The type of information you are authenticating with. We currently only support the password grant type", + "enum": [ACCESS_TOKEN_GRANT_TYPE.PASSWORD.value] + }, + "username": { + "type": "string", + "description": "The user name of the Specify User" + }, + "password": { + "type": "string", + "description": "The password for the Specify User" + }, + "collectionid": { + "type": "integer", + "description": "The ID of the Collection to scope this access token to. /context/login/ can be used to get Collection IDs if needed" + }, + "expires_in": { + "type": "integer", + "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS} seconds if not specified", + "default": DEFAULT_AUTH_LIFESPAN_SECONDS + } + }, + "required": ['grant_type', '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." + }, + "token_type": { + "type": "string", + "description": "The type of access token. Currently, only Bearer tokens are supported", + "enum": ["Bearer"] + } + }, + "required": ['access_token', 'expires_in', 'token_type'] + } + } + } + }, + "400": { + "description": "The request for an access token was invalid. See the error_description for more details. The error types are described in Section 5.2 of RFC 6749: https://www.rfc-editor.org/info/rfc6749/#section-5.2", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "error_description": { + "type": "string" + }, + }, + "required": ['error'] + } + } + } + }, + "401": { + "description": "The provided credentials were incorrect, or the user does not have access to the collection", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["invalid_client"] + }, + "error_description": { + "type": "string" + }, + }, + "required": ['error'] + } + } + } + }, + "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} + } + }, +}) +@require_POST +@csrf_exempt +def acquire_access_token(request: http.HttpRequest): + """ + Obtain an access token for use with the API. + + This endpoint follows the OAuth 2.0 Framework defined in RFC 6749: https://www.rfc-editor.org/info/rfc6749/. + Specify supports using bearer access tokens through an Authorization Bearer header as defined in RFC 6750: https://www.rfc-editor.org/info/rfc6750/. + + To use an access token, include it in future requests with an "Authorization: Bearer " header, replacing with the generated access token. + """ + # FEAT: Currently we only accept x-www-form-urlencoded because that is + # required by the OAuth 2.0 framework, see section 4.4.2 of RFC 6749: + # https://www.rfc-editor.org/info/rfc6749/#section-4.4.2 + # Though in the future we can allow this endpoint to also accept + # application/json bodies + grant_type = request.POST.get("grant_type") + + if grant_type is None: + error_response = { + "error": "invalid_request", + "error_description": f"A grant_type was not provided in access token request. Available grant_types: {list(ACCESS_TOKEN_GRANT_TYPE.types())}" + } + return http.JsonResponse(error_response, status=400) + + if grant_type == ACCESS_TOKEN_GRANT_TYPE.PASSWORD.value: + response = handle_password_token_grant(request) + return response + + error_response = { + "error": "unsupported_grant_type", + "error_description": f"Available grant_types: {list(ACCESS_TOKEN_GRANT_TYPE.types())}" + } + return http.JsonResponse(error_response, status=400) + + +@openapi(schema={ + 'post': { + "requestBody": { + "required": True, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "The access token to revoke" + }, + "token_type_hint": { + "type": "string", + "description": "A hint to the server about the type of token to revoke. This is ignored at the moment, but may have an effect in the future.", + "enum": ['access_token'] + } + }, + "required": ['token'], + 'additionalProperties': False + } + } + } + }, + "responses": { + "204": {"description": "The access token was revoked or is already invalid"}, + "403": {"description": "The user was not authenticated/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): + """ + Revoke/invalidate a previously generated access token. + You must be authenticated (e.g., from a Session or by Bearer token) to call this endpoint. + A bearer token that is used for authentication can be revoked. That is, a token can revoke itself. + """ + """ + Where possible, this endpoint should generally follow the OAuth 2.0 Spec + for Token Revocation as defined by RFC 7009: + https://www.rfc-editor.org/info/rfc7009/ + + FEAT: When refresh tokens are implemented, allow their revocation + """ + encoded_token = request.POST.get("token") + if encoded_token is None: + return http.HttpResponse('', status=204) + + token = decode_access_token(encoded_token) + + if token is None: + return http.HttpResponse('', status=204) + + revoke_token(token) + + return http.HttpResponse('', status=204) diff --git a/specifyweb/backend/cache/redis/connect.py b/specifyweb/backend/cache/redis/connect.py index 989abfb4d2f..c1b5589951b 100644 --- a/specifyweb/backend/cache/redis/connect.py +++ b/specifyweb/backend/cache/redis/connect.py @@ -42,6 +42,18 @@ def delete(self, key: str, format_key=True): key = _format_key(key) return self.connection.delete(key) + def exists(self, key: str, format_key=True) -> bool: + if format_key: + key = _format_key(key) + # Technically, exists accepts any number of keys and returns the number + # of keys that exist among all passed-in keys + # See https://redis.io/docs/latest/commands/exists/ + exists_in_redis = self.connection.exists(key) + # We're keeping the interface as simple and easy-to-use as possible + # though, so we currently treat exists as a function that accepts only + # a single key and returns whether or not that key exists in Redis. + return bool(exists_in_redis) + def key_type(self, key: str, format_key=True) -> Redis_Type: if format_key: key = _format_key(key) @@ -59,6 +71,9 @@ def connection(self): def delete(self, key: str, format_key=True): return self._established.delete(key, format_key=format_key) + def exists(self, key: str, format_key=True) -> bool: + return self._established.exists(key, format_key=format_key) + def key_type(self, key: str, format_key=True) -> Redis_Type: return self._established.key_type(key, format_key=format_key) diff --git a/specifyweb/backend/context/middleware.py b/specifyweb/backend/context/middleware.py index 51a8037c2e4..551bb335f79 100644 --- a/specifyweb/backend/context/middleware.py +++ b/specifyweb/backend/context/middleware.py @@ -3,11 +3,11 @@ """ from django.conf import settings -from django.http import HttpResponseBadRequest from django.utils.functional import SimpleLazyObject from specifyweb.specify.api.filter_by_col import filter_by_collection from specifyweb.specify.models import Collection, Specifyuser, Agent +from specifyweb.backend.context.views import users_collections_for_sp7 def get_cached(attr, func, request): @@ -31,7 +31,25 @@ def get_collection(request): try: collection_id = int(request.COOKIES.get('collection', '')) except ValueError: - return qs.all()[0] + """ + If the collection cookie is not set, try and scope the request to a + collection the user has access to. + If the user can't be inferred, default to the first collection. + """ + user = request.specify_user + if user is None: + return qs.all()[0] + user_collections = users_collections_for_sp7(user.id) + # Blegh, if the user doesn't have permission to any collections, use + # the default behavior of the first collection. + # Essentially, put the burden of authorization on the view: which is + # much easier to slip-up and make a mistake in (especially if the + # author is acting on implicit assumptions about security at the time + # of the view) + # TODO: handle such cases in this middleware. + if len(user_collections) <= 0: + return qs.all()[0] + return user_collections[0] else: return qs.get(id=collection_id) @@ -54,9 +72,15 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - request.specify_collection = SimpleLazyObject(lambda: get_cached('_cached_collection', get_collection, request)) - request.specify_user_agent = SimpleLazyObject(lambda: get_cached('_cached_agent', get_agent, request)) - request.specify_user = SimpleLazyObject(lambda: get_cached('_cached_specify_user', get_user, request)) + # These can be set by middleware "higher" in the chain/before this one, + # particularly when using a different authentiation scheme, such as via + # JWT Auth token + if not hasattr(request, "specify_collection"): + request.specify_collection = SimpleLazyObject(lambda: get_cached('_cached_collection', get_collection, request)) + if not hasattr(request, "specify_user_agent"): + request.specify_user_agent = SimpleLazyObject(lambda: get_cached('_cached_agent', get_agent, request)) + if not hasattr(request, "specify_user"): + request.specify_user = SimpleLazyObject(lambda: get_cached('_cached_specify_user', get_user, request)) return self.get_response(request) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index fb972295cf5..6ebe7778443 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -64,11 +64,14 @@ def users_collections_for_sp6(cursor, user_id): # pragma: no cover return list(cursor.fetchall()) +def has_collection_access(collection_id: int, user_id: int) -> bool: + return query_pt(collection_id, user_id, CollectionAccessPT.access).allowed + def users_collections_for_sp7(userid: int) -> list: # pragma: no cover return [ c for c in Collection.objects.all() - if query_pt(c.id, userid, CollectionAccessPT.access).allowed + if has_collection_access(c.id, userid) ] def set_users_collections_for_sp6(cursor, user, collectionids): # pragma: no cover diff --git a/specifyweb/settings/__init__.py b/specifyweb/settings/__init__.py index 65d8992cd9e..8f0ce4779a8 100644 --- a/specifyweb/settings/__init__.py +++ b/specifyweb/settings/__init__.py @@ -228,6 +228,7 @@ def get_sa_db_url(db_name): 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'specifyweb.backend.accounts.middleware.JWTAuthMiddleware', 'specifyweb.backend.context.middleware.ContextMiddleware', 'specifyweb.backend.permissions.middleware.PermissionsMiddleware', 'specifyweb.middleware.general.GeneralMiddleware',