Skip to content
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

feat: DIA-1839: Add JWT auth for API tokens #6996

Merged
merged 70 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from 65 commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
eb1f662
wip: add jwt auth
pakelley Jan 29, 2025
15a7e10
Merge branch 'develop' into 'fb-dia-1839'
pakelley Feb 1, 2025
9d3bf6d
only use jwt for orgs with it enabled
pakelley Feb 3, 2025
e577e97
add simplejwt dep
pakelley Feb 4, 2025
823d4fe
log when basic token auth is used
pakelley Feb 5, 2025
b82fa07
enable jwt token auth for newly created orgs
pakelley Feb 5, 2025
1397c0d
move basic jwt token to LSO
pakelley Feb 5, 2025
5924e15
fix app naming
pakelley Feb 5, 2025
4dfa205
Merge branch 'develop' into fb-dia-1839
pakelley Feb 5, 2025
d6b340e
update lockfile hash
pakelley Feb 5, 2025
7df9ed9
add missing serializer
pakelley Feb 5, 2025
56b8dfc
fix migration
pakelley Feb 5, 2025
81beb77
separate settings
pakelley Feb 5, 2025
e39d427
move middleware
pakelley Feb 6, 2025
6ebebe7
block old tokens
pakelley Feb 6, 2025
3108c36
add tests
pakelley Feb 6, 2025
4dc2e90
remove ttl_days
pakelley Feb 6, 2025
04f7d51
fix swagger docs
pakelley Feb 6, 2025
5effe4c
add tests
pakelley Feb 6, 2025
a96ad85
remove unnecessary fields
pakelley Feb 6, 2025
955d823
jwt access token FF
pakelley Feb 6, 2025
63a5a98
use standard .authenticate call
pakelley Feb 6, 2025
5f6a6f1
test: improve tests
pakelley Feb 7, 2025
37ff6fc
combine settings modules
pakelley Feb 7, 2025
408d70d
fallback to other auth if jwt fails
pakelley Feb 7, 2025
e81e814
add ability to revoke token
pakelley Feb 7, 2025
fdaacc7
allow admin to make jwt settings changes
pakelley Feb 7, 2025
0a3138f
typo
pakelley Feb 7, 2025
aa8c635
Merge branch 'develop' into fb-dia-1839
pakelley Feb 7, 2025
0ec9928
fix hash
pakelley Feb 7, 2025
8fca88d
improve serializer behavior
pakelley Feb 7, 2025
1b2407f
fix api docs
pakelley Feb 7, 2025
3e52496
lint
pakelley Feb 8, 2025
e2278fd
lint
pakelley Feb 8, 2025
41d0d9d
change log level to info
pakelley Feb 8, 2025
626b1e4
don't need hasattr
pakelley Feb 8, 2025
302ca78
separate settings for legacy vs jwt api tokens
pakelley Feb 8, 2025
a094e77
make tokens last effectively forever
pakelley Feb 10, 2025
c62e80b
better testing for auth enablement matrix
pakelley Feb 10, 2025
8182edd
fix token settings for new orgs
pakelley Feb 10, 2025
b1bf031
improve testing
pakelley Feb 10, 2025
f615c42
lint
pakelley Feb 10, 2025
af9e0be
improve exception handling
pakelley Feb 10, 2025
7fbeecc
add jwt urls
pakelley Feb 10, 2025
060883f
add utils file
pakelley Feb 10, 2025
8616f3a
fix imports
pakelley Feb 10, 2025
ee9a257
lint
pakelley Feb 10, 2025
322c50e
improve exception when user does not exist
pakelley Feb 10, 2025
2b15928
update urls
pakelley Feb 10, 2025
ed7742c
lint
pakelley Feb 10, 2025
b91195d
lint
pakelley Feb 10, 2025
97a466e
improve token list behavior
pakelley Feb 10, 2025
bb7040a
only return refresh tokens
pakelley Feb 10, 2025
3ab5841
lint
pakelley Feb 10, 2025
f91f92c
exclude blacklisted tokens from token list
pakelley Feb 10, 2025
f45a7de
return 404 when blacklisting already-blacklisted token
pakelley Feb 10, 2025
e5c6104
include legacy token settings in settings serializer
pakelley Feb 10, 2025
a9ad9cf
add tests for blacklist statuses
pakelley Feb 10, 2025
1bf2ef7
lint
pakelley Feb 10, 2025
4c782d2
only enable new auth if FF turned on
pakelley Feb 10, 2025
dd83f71
doc: get_queryset behavior
pakelley Feb 10, 2025
a720570
test: fix test
pakelley Feb 10, 2025
94555e4
don't 401 directly when JWT auth fails
pakelley Feb 10, 2025
7f70163
improve woding on legacy token auth phaseout
pakelley Feb 11, 2025
9e1ef8b
one more wording change
pakelley Feb 11, 2025
d998425
tidying
pakelley Feb 11, 2025
611e406
tidying
pakelley Feb 11, 2025
1f54189
updates from CR
pakelley Feb 11, 2025
0187cbb
Merge branch 'develop' into 'fb-dia-1839'
pakelley Feb 11, 2025
14f74c4
don't allow token management from admin console
pakelley Feb 11, 2025
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
96 changes: 96 additions & 0 deletions label_studio/core/all_urls.json
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,78 @@
"name": "",
"decorators": ""
},
{
"url": "/admin/token_blacklist/outstandingtoken/",
"module": "django.contrib.admin.options.changelist_view",
"name": "admin:token_blacklist_outstandingtoken_changelist",
"decorators": ""
},
{
"url": "/admin/token_blacklist/outstandingtoken/add/",
"module": "django.contrib.admin.options.add_view",
"name": "admin:token_blacklist_outstandingtoken_add",
"decorators": ""
},
{
"url": "/admin/token_blacklist/outstandingtoken/<path:object_id>/history/",
"module": "django.contrib.admin.options.history_view",
"name": "admin:token_blacklist_outstandingtoken_history",
"decorators": ""
},
{
"url": "/admin/token_blacklist/outstandingtoken/<path:object_id>/delete/",
"module": "django.contrib.admin.options.delete_view",
"name": "admin:token_blacklist_outstandingtoken_delete",
"decorators": ""
},
{
"url": "/admin/token_blacklist/outstandingtoken/<path:object_id>/change/",
"module": "django.contrib.admin.options.change_view",
"name": "admin:token_blacklist_outstandingtoken_change",
"decorators": ""
},
{
"url": "/admin/token_blacklist/outstandingtoken/<path:object_id>/",
"module": "django.views.generic.base.RedirectView",
"name": "",
"decorators": ""
},
{
"url": "/admin/token_blacklist/blacklistedtoken/",
"module": "django.contrib.admin.options.changelist_view",
"name": "admin:token_blacklist_blacklistedtoken_changelist",
"decorators": ""
},
{
"url": "/admin/token_blacklist/blacklistedtoken/add/",
"module": "django.contrib.admin.options.add_view",
"name": "admin:token_blacklist_blacklistedtoken_add",
"decorators": ""
},
{
"url": "/admin/token_blacklist/blacklistedtoken/<path:object_id>/history/",
"module": "django.contrib.admin.options.history_view",
"name": "admin:token_blacklist_blacklistedtoken_history",
"decorators": ""
},
{
"url": "/admin/token_blacklist/blacklistedtoken/<path:object_id>/delete/",
"module": "django.contrib.admin.options.delete_view",
"name": "admin:token_blacklist_blacklistedtoken_delete",
"decorators": ""
},
{
"url": "/admin/token_blacklist/blacklistedtoken/<path:object_id>/change/",
"module": "django.contrib.admin.options.change_view",
"name": "admin:token_blacklist_blacklistedtoken_change",
"decorators": ""
},
{
"url": "/admin/token_blacklist/blacklistedtoken/<path:object_id>/",
"module": "django.views.generic.base.RedirectView",
"name": "",
"decorators": ""
},
{
"url": "/admin/users/user/<id>/password/",
"module": "django.contrib.auth.admin.user_change_password",
Expand Down Expand Up @@ -1756,5 +1828,29 @@
"module": "django.contrib.auth.views.LogoutView",
"name": "rest_framework:logout",
"decorators": ""
},
{
"url": "/api/jwt/settings",
"module": "jwt_auth.views.JWTSettingsAPI",
"name": "jwt_auth:api-jwt-settings",
"decorators": ""
},
{
"url": "/api/token/",
"module": "jwt_auth.views.LSAPITokenView",
"name": "jwt_auth:token_manage",
"decorators": ""
},
{
"url": "/api/token/refresh/",
"module": "jwt_auth.views.DecoratedTokenRefreshView",
"name": "jwt_auth:token_refresh",
"decorators": ""
},
{
"url": "/api/token/blacklist/",
"module": "jwt_auth.views.LSTokenBlacklistView",
"name": "jwt_auth:token_blacklist",
"decorators": ""
}
]
1 change: 1 addition & 0 deletions label_studio/core/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ def process_request(self, request) -> None:
or
# scim assign request.user implicitly, check CustomSCIMAuthCheckMiddleware
(hasattr(request, 'is_scim') and request.is_scim)
or (hasattr(request, 'is_jwt') and request.is_jwt)
):
return

Expand Down
5 changes: 4 additions & 1 deletion label_studio/core/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@
'annoying',
'rest_framework',
'rest_framework.authtoken',
'rest_framework_simplejwt.token_blacklist',
'drf_generators',
'core',
'users',
Expand All @@ -229,6 +230,7 @@
'labels_manager',
'ml_models',
'ml_model_providers',
'jwt_auth',
]

MIDDLEWARE = [
Expand All @@ -247,12 +249,13 @@
'core.middleware.ContextLogMiddleware',
'core.middleware.DatabaseIsLockedRetryMiddleware',
'core.current_request.ThreadLocalMiddleware',
'jwt_auth.middleware.JWTAuthenticationMiddleware',
]

REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.TokenAuthentication',
'jwt_auth.auth.TokenAuthenticationPhaseout',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': [
Expand Down
1 change: 1 addition & 0 deletions label_studio/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
path('heidi-tips/', views.heidi_tips, name='heidi_tips'),
path('__lsa/', views.collect_metrics, name='collect_metrics'),
re_path(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
re_path(r'^', include('jwt_auth.urls')),
]

if settings.DEBUG:
Expand Down
5 changes: 5 additions & 0 deletions label_studio/jwt_auth/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class JWTAuthConfig(AppConfig):
name = 'jwt_auth'
35 changes: 35 additions & 0 deletions label_studio/jwt_auth/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import logging

from rest_framework.authentication import TokenAuthentication
from rest_framework.exceptions import AuthenticationFailed

logger = logging.getLogger(__name__)


class TokenAuthenticationPhaseout(TokenAuthentication):
"""TokenAuthentication with features to help phase out legacy token auth

Logs usage and triggers a 401 if legacy token auth is not enabled for the organization."""

def authenticate(self, request):
"""Authenticate the request and log if successful."""
from core.feature_flags import flag_set

auth_result = super().authenticate(request)
JWT_ACCESS_TOKEN_ENABLED = flag_set('fflag__feature_develop__prompts__dia_1829_jwt_token_auth')
if JWT_ACCESS_TOKEN_ENABLED and (auth_result is not None):
user, _ = auth_result
org = user.active_organization
org_id = org.id if org else None

# raise 401 if legacy API token auth disabled (i.e. this token is no longer valid)
if org and (not org.jwt.legacy_api_tokens_enabled):
raise AuthenticationFailed(
'Authentication token no longer valid: legacy token authentication has been disabled for this organization'
)

logger.info(
'Legacy token authentication used',
extra={'user_id': user.id, 'organization_id': org_id, 'endpoint': request.path},
)
return auth_result
39 changes: 39 additions & 0 deletions label_studio/jwt_auth/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import logging

from django.contrib.auth import get_user_model
from django.http import JsonResponse
from rest_framework import status

logger = logging.getLogger(__name__)

User = get_user_model()


class JWTAuthenticationMiddleware:
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
from core.feature_flags import flag_set
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import AuthenticationFailed, InvalidToken, TokenError

JWT_ACCESS_TOKEN_ENABLED = flag_set('fflag__feature_develop__prompts__dia_1829_jwt_token_auth')
if JWT_ACCESS_TOKEN_ENABLED:
try:
user_and_token = JWTAuthentication().authenticate(request)
if not user_and_token:
return self.get_response(request)

user = User.objects.get(pk=user_and_token[0].pk)
if user.active_organization.jwt.api_tokens_enabled:
request.user = user
request.is_jwt = True
except User.DoesNotExist:
logger.info('JWT authentication failed: User no longer exists')
return JsonResponse({'detail': 'User not found'}, status=status.HTTP_401_UNAUTHORIZED)
except (AuthenticationFailed, InvalidToken, TokenError) as e:
logger.info('JWT authentication failed: %s', e)
# don't raise 401 here, fallback to other auth methods (in case token is valid for them)
# (have unit tests verifying that this still results in a 401 if other auth mechanisms fail)
return self.get_response(request)
63 changes: 63 additions & 0 deletions label_studio/jwt_auth/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Generated by Django 5.1.4 on 2025-02-03 15:51

import annoying.fields
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
("organizations", "0006_alter_organizationmember_deleted_at"),
]

operations = [
migrations.CreateModel(
name="jwtsettings",
fields=[
(
"organization",
annoying.fields.AutoOneToOneField(
on_delete=django.db.models.deletion.DO_NOTHING,
primary_key=True,
related_name='jwt',
serialize=False,
to='organizations.organization'
)
),
(
"api_tokens_enabled",
models.BooleanField(
default=False,
help_text="Enable JWT API token authentication for this organization",
verbose_name="JWT API tokens enabled",
),
),
(
'api_token_ttl_days',
models.IntegerField(
default=30,
help_text='Number of days before JWT API tokens expire',
verbose_name='JWT API token time to live (days)')
),
(
"legacy_api_tokens_enabled",
models.BooleanField(
default=True,
help_text="Enable legacy API token authentication for this organization",
verbose_name="legacy API tokens enabled",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
],
),
]
Empty file.
Loading
Loading