Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions dev/private_key_development.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCe0X6Mm6YHNsXb
355ujaeVRqkmJGZNNl3920BVM3NN9b5NBSOF+bmUZeMej6Gh0Zpv+qMIq2ddtgXJ
oVTF0PyHvxZpcj8wE6iHxETkiBo61Kt8Vll8RpWXuXkwIiY1camDNWch1vzLZpyZ
sG4QcI2nmhLInoWfREWrWwg9mA9Uh11THLa7aP4zdq53e1qb80gH64coCHW6UuJT
j0oNmwsXCVzyOywAhZyy7H60MsbWHZc/pdby9/NrNuhd+OooUXtbnbN9eUj2HmGx
dHIVHbxuEWCwsAafdSUGIDXVwnO8w6ZDVTdcMKoBfll3l//bJqpaguPXcVBrtbvf
vp1xxFRxAgMBAAECggEAE03NGc53b+wJQPryw7xFu4ANt5xNWk2P6iG4Cu3Ih5gl
loYCiy/POJ7pFsIIODuFD98BPwtacopZNL3+fawzwv+H0VmjcuFeJuEagJlHGucr
pYl3XK2AVE36fCPCd3+GcPN7mCI3XYpuNsNk9WxA2OIs2KQQA3ZQbji6jTC04vtU
iNezp3hFW3MVVehKX+5le1NpZylTyqg0+LL5j/+BHmqxiu12qvZbxanZ7diRgwqI
fjRfTUJ50gMUarYYaXuno6ZAON9SRtgueKmWfmcgKjUlJXX40LR1CbPoEQ8A72fd
YngS4ffHjo2zdqUn683qw5gznRFg36qJzh2f8TgRcwKBgQDdH/U72RLE9uAFQNMU
lyCHaqyd9+j+U3npiNkGfHnnLaYK6O6hHBw9XoiYfAnBDfcOYeWFmWDBnLw/N95y
H+r8pV+oGhVeP4VGpwycCRuvMxp7cVbq2kkXgtjLfhYx1Asuf/DdvlQ84ENuSMCu
2Kq90KLaHqDZ+w10XH0cNgpXhwKBgQC33eAzHxmc6JxFPtqG8I6u4ibEudWYV8qR
+iHYf0Zqbb70rddua8c6H+10WGu3oZhXnnNWkAsqJGgakAREzxIanbFqfpZ1ZtA1
+2alupWeUF+7XoR1RwkKysUjbWlWUbKvO20MRunxzvdwzG14Y8ungITaQHzB+M3l
nMJQfdECRwKBgQDBYiiTbZVnokxq67VuZXjyVQ2fnWcrvQ96eM7sSEJINnjnQ60m
QzJDTYCCcsAJEVCGSIF1ZJzk1lEfrJmjD1zwFSTiG+WiJkVFc+SoNaL7huLbIFUW
UU7o++rjlGKOs1YQFZ4uHz0GfE8cjQ3OG/i+xk8WGQEtgczTfeuAl5ZV0wKBgE72
QV+S/pvtJZdzW8PRsWUXiFC6AinvofY49qoUVrhEM1q/AaLRNHkY1xA9HN16z4Lp
cFz/dVv+0Jp/uOWYDA1UJao3fQQkSEy2j6mizLh1ifdcqwP2osJ4vFrvlOpWIaex
nK5GEhgfqxJNKMIoEYD455UXVryyzjHKtYR90/HnAoGBAMDZPN0V7pW7CCzSpC2F
8bfZmpm75XZDakgHWHPzj/uUMQ0EFR871+v8eI4F27gfoxerfwKOvudFPpCRrg7M
3FqvZFduVdm51nd7QfqdmsJiRZC6BgqZkU9F0cTw3kc1DTPTD9J/vd1fl1Np4/Dy
BEfjGnMsRoqoJ2JEXyyDhJiw
-----END PRIVATE KEY-----
65 changes: 32 additions & 33 deletions isic/auth.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,43 @@
from collections.abc import Callable
from typing import Literal

from ninja.security import HttpBearer, django_auth
from oauth2_provider.oauth2_backends import get_oauthlib_core
from django.http import HttpRequest
from ninja.security import django_auth

from isic.core.permissions import SessionAuthStaffUser

ACCESS_PERMS = ["any", "is_authenticated", "is_staff"]

from allauth.idp.oidc.contrib.ninja.security import TokenAuth # noqa: E402

class OAuth2AuthBearer(HttpBearer):
def __init__(self, perm: str):
if perm not in ACCESS_PERMS:
raise ValueError(f"Invalid permission: {perm}")
self.perm = perm
super().__init__()

# This is a reimplementation of the django-oauth-toolkit authentication backend for DRF.
# See https://github.com/jazzband/django-oauth-toolkit/blob/a4ae1d4716bcabe45d80a787f4064022f11e584f/oauth2_provider/contrib/rest_framework/authentication.py#L8 # noqa: E501
def authenticate(self, request, token):
oauthlib_core = get_oauthlib_core()
valid, r = oauthlib_core.verify_request(request, scopes=[])

if valid:
# See https://github.com/vitalik/django-ninja/issues/76 for why we have to manually set
# request.user here.
request.user = r.user

if self.perm == "any":
return r.user, token
if self.perm == "is_authenticated" and r.user.is_authenticated:
return r.user, token
if self.perm == "is_staff" and r.user.is_authenticated and r.user.is_staff:
return r.user, token
elif self.perm == "any":
return True
else:
request.oauth2_error = getattr(r, "oauth2_error", {})

class PermissionedTokenAuth(TokenAuth):
def __init__(
self, permission: Literal["any", "is_authenticated", "is_staff"], scope: str | list | dict
):
if permission not in ACCESS_PERMS:
raise ValueError(f"Invalid permission: {permission}")

super().__init__(scope)
self.permission = permission

def __call__(self, request: HttpRequest):
result = super().__call__(request)
if result is not None:
if self.permission == "any":
return result
if self.permission == "is_authenticated" and request.user.is_authenticated:
return result
if (
self.permission == "is_staff"
and request.user.is_authenticated
and request.user.is_staff
):
return result
return self.permission == "any"


# The lambda _: True is to handle the case where a user doesn't pass any authentication.
allow_any: list[Callable] = [django_auth, OAuth2AuthBearer("any"), lambda _: True]
is_authenticated = [django_auth, OAuth2AuthBearer("is_authenticated")]
is_staff = [SessionAuthStaffUser(), OAuth2AuthBearer("is_staff")]
allow_any: list[Callable] = [django_auth, PermissionedTokenAuth("any", scope=[]), lambda _: True]
is_authenticated = [django_auth, PermissionedTokenAuth("is_authenticated", scope=[])]
is_staff = [SessionAuthStaffUser(), PermissionedTokenAuth("is_staff", scope=[])]
5 changes: 2 additions & 3 deletions isic/core/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import oauth2_provider.generators
import s3_file_field.fields


Expand Down Expand Up @@ -376,7 +375,7 @@ class Migration(migrations.Migration):
"client_id",
models.CharField(
db_index=True,
default=oauth2_provider.generators.generate_client_id,
default="placeholder",
max_length=100,
unique=True,
),
Expand Down Expand Up @@ -413,7 +412,7 @@ class Migration(migrations.Migration):
models.CharField(
blank=True,
db_index=True,
default=oauth2_provider.generators.generate_client_secret,
default="",
max_length=255,
),
),
Expand Down
15 changes: 15 additions & 0 deletions isic/core/migrations/0027_delete_isicoauthapplication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Generated by Django 5.2.3 on 2025-07-24 22:20

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("core", "0026_invert_doi_fk"),
]

operations = [
migrations.DeleteModel(
name="IsicOAuthApplication",
),
]
3 changes: 1 addition & 2 deletions isic/core/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from django.db.models.signals import post_save
from django.dispatch import receiver

from .base import CopyrightLicense, CreationSortedTimeStampedModel, IsicOAuthApplication
from .base import CopyrightLicense, CreationSortedTimeStampedModel
from .collection import Collection
from .collection_count import CollectionCount
from .doi import Doi
Expand All @@ -24,7 +24,6 @@
"Image",
"ImageAlias",
"IsicId",
"IsicOAuthApplication",
"Segmentation",
"SegmentationReview",
"SupplementalFile",
Expand Down
16 changes: 0 additions & 16 deletions isic/core/models/base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import re

from django.db import models
from django_extensions.db.fields import CreationDateTimeField
from django_extensions.db.models import TimeStampedModel
from oauth2_provider.models import AbstractApplication


class CreationSortedTimeStampedModel(TimeStampedModel):
Expand All @@ -23,16 +20,3 @@ class CopyrightLicense(models.TextChoices):
# These 2 require attribution
CC_BY = "CC-BY", "CC-BY"
CC_BY_NC = "CC-BY-NC", "CC-BY-NC"


class IsicOAuthApplication(AbstractApplication):
class Meta:
verbose_name = "ISIC OAuth application"

def redirect_uri_allowed(self, uri):
"""Allow regex matching, in addition to the normal behavior."""
for redirect_uri in self.redirect_uris.split():
if redirect_uri.startswith("^") and re.match(redirect_uri, uri):
return True

return super().redirect_uri_allowed(uri)
6 changes: 0 additions & 6 deletions isic/core/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from django.db import connection, transaction
from django.db.models import Prefetch
from django.template.loader import render_to_string
from oauth2_provider.models import clear_expired as clear_expired_oauth_tokens
import requests
from resonant_utils.storages import expiring_url
from urllib3.exceptions import ConnectionError as Urllib3ConnectionError
Expand Down Expand Up @@ -187,11 +186,6 @@ def generate_archive_snapshot_task() -> None:
Path(metadata_filename).unlink()


@shared_task(soft_time_limit=10, time_limit=15)
def prune_expired_oauth_tokens():
clear_expired_oauth_tokens()


@shared_task(soft_time_limit=90, time_limit=120)
def refresh_materialized_view_collection_counts_task():
with connection.cursor() as cursor:
Expand Down
78 changes: 41 additions & 37 deletions isic/core/tests/test_isic_oauth_app.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,49 @@
from base64 import b64encode
from datetime import timedelta
import secrets

from allauth.core import context
from allauth.idp.oidc.adapter import get_adapter
from allauth.idp.oidc.models import Client, Token
from django.test import RequestFactory
from django.urls import path
from django.utils import timezone
from ninja import NinjaAPI
from oauth2_provider.models import get_access_token_model, get_application_model
import pytest

from isic import auth
from isic.core.models.base import IsicOAuthApplication


@pytest.fixture
def oauth_app(user_factory):
user = user_factory()
return get_application_model().objects.create(
return Client.objects.create(
name="Test Application",
redirect_uris="http://localhost",
user=user,
client_type=get_application_model().CLIENT_CONFIDENTIAL,
authorization_grant_type=get_application_model().GRANT_AUTHORIZATION_CODE,
scopes="openid",
type=Client.Type.PUBLIC,
grant_types=Client.GrantType.DEVICE_CODE,
redirect_uris="http://foo.com",
response_types="code",
skip_consent=False,
)


@pytest.fixture
def oauth_token_factory(oauth_app):
def f(user):
return get_access_token_model().objects.create(
token = secrets.token_hex(8)
oauth_app.token_set.create(
user=user,
expires=timezone.now() + timedelta(seconds=300),
token="some-token",
application=oauth_app,
expires_at=timezone.now() + timedelta(seconds=300),
hash=get_adapter().hash_token(token),
type=Token.Type.ACCESS_TOKEN,
scopes=["openid"],
)
return token

return f


@pytest.mark.skip(reason="TODO: needs to be ported to allauth")
@pytest.mark.django_db
@pytest.mark.parametrize(
("uri", "allowed_uris", "allowed"),
Expand All @@ -47,22 +55,11 @@ def f(user):
],
)
def test_redirect_uri_allowed(user, uri, allowed_uris, allowed):
app = IsicOAuthApplication.objects.create(
name="Test Application",
redirect_uris=allowed_uris,
user=user,
client_type=get_application_model().CLIENT_CONFIDENTIAL,
authorization_grant_type=get_application_model().GRANT_AUTHORIZATION_CODE,
)

assert app.redirect_uri_allowed(uri) == allowed
pass


@pytest.fixture
def test_oauth_api_endpoints(request):
# this is pretty gross, but DOT requires a "more" real request object be created, meaning the
# ninja test client can't be used since it mocks it. using the django test client means we have
# to add real routes and then remove them.
api = NinjaAPI(urls_namespace=request.function.__name__, auth=auth.allow_any)

@api.get("/allow-any")
Expand All @@ -89,8 +86,7 @@ def is_staff_view(request):


def get_bearer_token(user, oauth_token_factory):
token = oauth_token_factory(user)
return token.token
return oauth_token_factory(user)


@pytest.mark.django_db
Expand Down Expand Up @@ -180,16 +176,24 @@ def test_is_staff_with_nonstaff_bearer_token(client, nonstaff_user, oauth_token_
assert response.status_code == 401


def test_oauth2authbearer_any_accepts_invalid_token():
bearer = auth.OAuth2AuthBearer("any")
request = RequestFactory().get("/")
result = bearer.authenticate(request, "invalidtoken")
assert result is True
@pytest.mark.django_db
@pytest.mark.usefixtures("test_oauth_api_endpoints")
def test_permissioned_token_auth_invalid_token():
request = RequestFactory(
headers={"Authorization": f"Bearer {b64encode(b'invalidtoken').decode()}"}
).get("/test-oauth/allow-any")

token_auth = auth.PermissionedTokenAuth("any", scope=[])

# allauth APIs assume a global request context, so we need to set it up manually
with context.request_context(request):
result = token_auth(request)
assert result is True

bearer = auth.OAuth2AuthBearer("is_authenticated")
result = bearer.authenticate(request, "invalidtoken")
assert result is None
token_auth = auth.PermissionedTokenAuth("is_authenticated", scope=[])
result = token_auth(request)
assert result is False

bearer = auth.OAuth2AuthBearer("is_staff")
result = bearer.authenticate(request, "invalidtoken")
assert result is None
token_auth = auth.PermissionedTokenAuth("is_staff", scope=[])
result = token_auth(request)
assert result is False
Loading
Loading