diff --git a/Dockerfile b/Dockerfile index 7f05311a171..415459e5da0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -230,8 +230,18 @@ EOF RUN echo "import os \nDEBUG = os.getenv('SP7_DEBUG', '').lower() == 'true'\n" \ > settings/debug.py -RUN echo "import os \nSECRET_KEY = os.environ['SECRET_KEY']\n" \ - > settings/secret_key.py +RUN cat < settings/secret_key.py +import os +DEFAULT_KEY="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 50)" +CURRENT_KEY=os.getenv("SECRET_KEY") + +if CURRENT_KEY is None or CURRENT_KEY.strip() == "" or CURRENT_KEY.strip().replace(" ", "_") == "change_this_to_some_unique_random_string": + new_key = DEFAULT_KEY +else: + new_key = CURRENT_KEY + +SECRET_KEY=new_key +EOF ENV LC_ALL=C.UTF-8 ENV LANG=C.UTF-8 diff --git a/specifyweb/backend/accounts/access_token_utils.py b/specifyweb/backend/accounts/access_token_utils.py new file mode 100644 index 00000000000..d9c1b69d694 --- /dev/null +++ b/specifyweb/backend/accounts/access_token_utils.py @@ -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]) + 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}") diff --git a/specifyweb/backend/accounts/middleware.py b/specifyweb/backend/accounts/middleware.py new file mode 100644 index 00000000000..6a394c4f705 --- /dev/null +++ b/specifyweb/backend/accounts/middleware.py @@ -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\"' + 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) diff --git a/specifyweb/backend/accounts/urls.py b/specifyweb/backend/accounts/urls.py index c088c507a26..d1120b4808a 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 22f1e477401..6068e8f209d 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -6,11 +6,10 @@ 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 import login, authenticate from django.contrib.auth.models import AbstractBaseUser from django.db import connection from django.db.models import Max @@ -19,6 +18,7 @@ from django.utils import crypto 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 typing import cast from specifyweb.backend.accounts.account_utils import check_collection_access_against_agents, is_provider_info @@ -27,15 +27,17 @@ 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 -from specifyweb.specify.models import Specifyuser +from specifyweb.specify.models import Specifyuser, Collection from django.views.decorators.http import require_POST from specifyweb.backend.permissions.permissions import check_permission_targets from specifyweb.specify.auth.support_login import b64_url_to_bytes +from specifyweb.backend.accounts.access_token_utils import DEFAULT_AUTH_LIFESPAN_SECONDS, generate_access_token, revoke_access_token as revoke_token, AUTH_JWT_DECODE_OPTIONS, AUTH_TOKEN_ALGORITHMS from django.db import transaction, connection logger = logging.getLogger(__name__) @@ -293,7 +295,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.backend.setup_tool.api import filter_ready_collections_for_config_tasks @@ -354,7 +355,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"]) @@ -580,3 +580,147 @@ def set_admin_status(request, userid): else: user.clear_admin() return http.HttpResponse('false', content_type='text/plain') + + +@openapi(schema={ + 'post': { + "requestBody": { + "required": True, + "description": "Obtain an access token that can be used with the API", + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "collectionid": { + "type": "integer" + }, + "expires": { + "type": "integer", + "description": f"The number of seconds this token should be valid for. Defaults to {DEFAULT_AUTH_LIFESPAN_SECONDS / 60} minutes if not specified", + "default": DEFAULT_AUTH_LIFESPAN_SECONDS + } + }, + "required": ['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." + } + } + } + } + } + }, + "400": {"description": "One of the required keys was not supplied, or one or more keys were supplied incorrectly"}, + "403": {"description": "The provided credentials were incorrect, or the user does not have access to the collection"}, + "405": {"description": "A non-POST method was made to the endpoint. Only POST is supported"} + } + }, +}) +@require_POST +@csrf_exempt +def acquire_access_token(request): + username = request.POST.get("username") + password = request.POST.get("password") + collection_id = request.POST.get("collectionid") + raw_expires_in = request.POST.get("expires", DEFAULT_AUTH_LIFESPAN_SECONDS) + + if None in (username, password, collection_id): + return http.HttpResponseBadRequest(f"username, password, and collection are required") + + try: + expires_in = int(raw_expires_in) + if expires_in <= 0: + return http.HttpResponseBadRequest(f"Invalid expiry time") + except ValueError: + return http.HttpResponseBadRequest(f"Expiry time could be parsed as integer") + + + try: + collection = Collection.objects.get(id=collection_id) + except Collection.DoesNotExist: + return http.HttpResponseBadRequest(f'collection {collection_id} does not exist') + + user = authenticate(username=username, password=password) + + if user is None or not has_collection_access(collection.id, user.id): + return http.HttpResponseForbidden() + + token = generate_access_token(user, collection.id, expires_in=expires_in) + + # TODO: lower default expiry time and also 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 + } + return http.JsonResponse(response) + + +@openapi(schema={ + 'post': { + "requestBody": { + "required": True, + "description": "Revoke a previously granted access token", + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "description": "The JWT Access token to revoke" + }, + }, + "required": ['access_token'], + 'additionalProperties': False + } + } + } + }, + "responses": { + "204": {"description": "The access token was revoked"}, + "400": {"description": "The provided token is already invalid or could not be validated"}, + "403": {"description": "The user was not 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): + encoded_token = request.POST.get("access_token") + + try: + token = jwt.decode(encoded_token, settings.SECRET_KEY, + options=AUTH_JWT_DECODE_OPTIONS, algorithms=AUTH_TOKEN_ALGORITHMS) + except jwt.exceptions.InvalidTokenError: + return http.HttpResponseBadRequest() + + revoke_token(token) + + return http.HttpResponse('', status=204) 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 c3ad4416777..2b18aa8a136 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -57,11 +57,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/backend/redis_cache/store.py b/specifyweb/backend/redis_cache/store.py index 102121839b0..27064c036e6 100644 --- a/specifyweb/backend/redis_cache/store.py +++ b/specifyweb/backend/redis_cache/store.py @@ -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 def set_string(key: str | bytes, value: str, time_to_live=None, override_existing=True): @@ -35,3 +35,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) diff --git a/specifyweb/backend/redis_cache/utils.py b/specifyweb/backend/redis_cache/utils.py index 5682b559358..9c51b2afd3b 100644 --- a/specifyweb/backend/redis_cache/utils.py +++ b/specifyweb/backend/redis_cache/utils.py @@ -9,15 +9,19 @@ def redis_connection(decode_responses=True): redis_port = getattr(settings, "REDIS_PORT", None) redis_db_index = getattr(settings, "REDIS_DB_INDEX", 0) if None in (redis_host, redis_port, redis_db_index): - raise ValueError("Redis is not correctly configured", redis_host, redis_port) + raise ValueError("Redis is not correctly configured", + redis_host, redis_port) return Redis(host=redis_host, port=redis_port, db=redis_db_index, decode_responses=decode_responses) + @overload def format_key(key: str) -> str: ... + @overload def format_key(key: bytes) -> bytes: ... + def format_key(key: str | bytes) -> str | bytes: """Formats key to avoid collisions when specify instances are sharing a cache. Expected format: specify:{database}:app:name""" @@ -26,10 +30,12 @@ def format_key(key: str | bytes) -> str | bytes: db_name = getattr(settings, "DATABASE_NAME") return key.format(database=db_name) + def _delete_key(key: str): host = redis_connection() host.delete(key) + def _set_string(key: str, value: str, time_to_live=None, override_existing=True, decode_responses=True): host = redis_connection(decode_responses=decode_responses) # See https://redis.readthedocs.io/en/stable/commands.html#redis.commands.core.CoreCommands.set @@ -41,39 +47,52 @@ def _set_string(key: str, value: str, time_to_live=None, override_existing=True, @overload -def _get_string(key: str, delete_key: bool, decode_responses: True) -> str | None: ... +def _get_string(key: str, delete_key: bool, + decode_responses: True) -> str | None: ... @overload -def _get_string(key: str, delete_key: bool, decode_responses: False) -> bytes | None: ... +def _get_string(key: str, delete_key: bool, + decode_responses: False) -> bytes | None: ... -def _get_string(key: str, delete_key: bool=False, decode_responses=True) -> str | bytes | None: +def _get_string(key: str, delete_key: bool = False, decode_responses=True) -> str | bytes | None: host = redis_connection(decode_responses=decode_responses) - if delete_key: + if delete_key: return host.getdel(key) - + return host.get(key) + def _add_to_set(key: str, *elements: str): if len(elements) <= 0: return 0 host = redis_connection(decode_responses=True) return host.sadd(key, *elements) + def _remove_from_set(key: str, *elements: str) -> int: if len(elements) <= 0: return 0 host = redis_connection(decode_responses=True) return host.srem(key, *elements) + def _set_elements(key: str) -> set: host = redis_connection(decode_responses=True) return host.smembers(key) + # https://redis.io/docs/latest/commands/type/ -Redis_Type = Literal["none", "string", "list", "set", "hash", "stream", "vectorset"] +Redis_Type = Literal["none", "string", "list", + "set", "hash", "stream", "vectorset"] + def _redis_type(key: str) -> Redis_Type: host = redis_connection(decode_responses=True) - return host.type(key) \ No newline at end of file + return host.type(key) + + +def _key_exists(key: str) -> bool: + host = redis_connection(decode_responses=True) + return host.exists(key) diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index 26f4696c407..790b3373284 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -395,9 +395,7 @@ export const notificationRenderers: IR< ); }, 'collection-creation-starting'() { - return ( -

{setupToolText.collectionCreationStarted()}

- ); + return

{setupToolText.collectionCreationStarted()}

; }, default(notification) { console.error('Unknown notification type', { notification }); diff --git a/specifyweb/settings/__init__.py b/specifyweb/settings/__init__.py index 8430307b407..87f24122b6c 100644 --- a/specifyweb/settings/__init__.py +++ b/specifyweb/settings/__init__.py @@ -233,6 +233,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',