mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Api token updates (#5664)
* Create new APIToken model - Has custom 'name' field - Has custom expiry date * Add data migration to port across any existing user tokens * Adds 'revoked' field - tokens can be manually revoked * Update API token - allow multiple tokens per user * Custom token auth handler - Correctly handles revoked tokens - Correctly handles expired tokens * Update AuthRequiredMiddleware - Check for token active status * Token API endpoint improvements - Can return tokens with custom names - Return more information on the token too * Consolidate migrations * When requesting a token, overwrite inactive token for authenticated user - An authenticated user must receive a token - Unauthenticated users cannot do this * Fix * Use token name for frontend * Force token expiry, and generate default expiry date * Force generation of a new token when requested * Reduce data exposed on token API endpoint * Display redacted token in admin site * Log when new token is created for user * Add default value for token - Allows raw token to be viewed in the admin interface when created - After created, no longer visible - Also provides ability to generate token with static prefix * Fixes for admin interface - Prevent user and expiry from being edited after creation * Implement unit tests for token functionality * Fix content exclude for import/export * Fix typo * Further tweaks - Prevent editing of "name" field after creation - Add isoformat date suffix to token * Longer token requires longer database field! * Fix other API tokens * Remove 'delete' method from token API endpoint * Bump API version
This commit is contained in:
parent
25138300ff
commit
23ea746813
@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 139
|
INVENTREE_API_VERSION = 140
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v140 -> 2023-10-20 : https://github.com/inventree/InvenTree/pull/5664
|
||||||
|
- Expand API token functionality
|
||||||
|
- Multiple API tokens can be generated per user
|
||||||
|
|
||||||
v139 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5509
|
v139 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5509
|
||||||
- Add new BarcodePOReceive endpoint to receive line items by scanning supplier barcodes
|
- Add new BarcodePOReceive endpoint to receive line items by scanning supplier barcodes
|
||||||
|
|
||||||
|
@ -12,9 +12,9 @@ from django.urls import Resolver404, include, re_path, resolve, reverse_lazy
|
|||||||
from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
|
from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
|
||||||
BaseRequire2FAMiddleware)
|
BaseRequire2FAMiddleware)
|
||||||
from error_report.middleware import ExceptionProcessor
|
from error_report.middleware import ExceptionProcessor
|
||||||
from rest_framework.authtoken.models import Token
|
|
||||||
|
|
||||||
from InvenTree.urls import frontendpatterns
|
from InvenTree.urls import frontendpatterns
|
||||||
|
from users.models import ApiToken
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
logger = logging.getLogger("inventree")
|
||||||
|
|
||||||
@ -75,13 +75,15 @@ class AuthRequiredMiddleware(object):
|
|||||||
|
|
||||||
# Does the provided token match a valid user?
|
# Does the provided token match a valid user?
|
||||||
try:
|
try:
|
||||||
token = Token.objects.get(key=token_key)
|
token = ApiToken.objects.get(key=token_key)
|
||||||
|
|
||||||
# Provide the user information to the request
|
if token.active and token.user:
|
||||||
request.user = token.user
|
|
||||||
authorized = True
|
|
||||||
|
|
||||||
except Token.DoesNotExist:
|
# Provide the user information to the request
|
||||||
|
request.user = token.user
|
||||||
|
authorized = True
|
||||||
|
|
||||||
|
except ApiToken.DoesNotExist:
|
||||||
logger.warning("Access denied for unknown token %s", token_key)
|
logger.warning("Access denied for unknown token %s", token_key)
|
||||||
|
|
||||||
# No authorization was found for the request
|
# No authorization was found for the request
|
||||||
|
@ -232,7 +232,6 @@ INSTALLED_APPS = [
|
|||||||
# Third part add-ons
|
# Third part add-ons
|
||||||
'django_filters', # Extended filter functionality
|
'django_filters', # Extended filter functionality
|
||||||
'rest_framework', # DRF (Django Rest Framework)
|
'rest_framework', # DRF (Django Rest Framework)
|
||||||
'rest_framework.authtoken', # Token authentication for API
|
|
||||||
'corsheaders', # Cross-origin Resource Sharing for DRF
|
'corsheaders', # Cross-origin Resource Sharing for DRF
|
||||||
'crispy_forms', # Improved form rendering
|
'crispy_forms', # Improved form rendering
|
||||||
'import_export', # Import / export tables to file
|
'import_export', # Import / export tables to file
|
||||||
@ -433,7 +432,7 @@ REST_FRAMEWORK = {
|
|||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
'rest_framework.authentication.BasicAuthentication',
|
'rest_framework.authentication.BasicAuthentication',
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
'rest_framework.authentication.TokenAuthentication',
|
'users.authentication.ApiTokenAuthentication',
|
||||||
),
|
),
|
||||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
|
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
@ -445,7 +444,8 @@ REST_FRAMEWORK = {
|
|||||||
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
|
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
|
||||||
'DEFAULT_RENDERER_CLASSES': [
|
'DEFAULT_RENDERER_CLASSES': [
|
||||||
'rest_framework.renderers.JSONRenderer',
|
'rest_framework.renderers.JSONRenderer',
|
||||||
]
|
],
|
||||||
|
'TOKEN_MODEL': 'users.models.ApiToken',
|
||||||
}
|
}
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
|
@ -9,11 +9,26 @@ from django.contrib.auth.models import Group
|
|||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from users.models import Owner, RuleSet
|
from users.models import ApiToken, Owner, RuleSet
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin class for the ApiToken model."""
|
||||||
|
|
||||||
|
list_display = ('token', 'user', 'name', 'expiry', 'active')
|
||||||
|
fields = ('token', 'user', 'name', 'revoked', 'expiry')
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
"""Some fields are read-only after creation"""
|
||||||
|
|
||||||
|
if obj:
|
||||||
|
return ['token', 'user', 'expiry', 'name']
|
||||||
|
else:
|
||||||
|
return ['token']
|
||||||
|
|
||||||
|
|
||||||
class RuleSetInline(admin.TabularInline):
|
class RuleSetInline(admin.TabularInline):
|
||||||
"""Class for displaying inline RuleSet data in the Group admin page."""
|
"""Class for displaying inline RuleSet data in the Group admin page."""
|
||||||
|
|
||||||
@ -239,3 +254,5 @@ admin.site.unregister(User)
|
|||||||
admin.site.register(User, InvenTreeUserAdmin)
|
admin.site.register(User, InvenTreeUserAdmin)
|
||||||
|
|
||||||
admin.site.register(Owner, OwnerAdmin)
|
admin.site.register(Owner, OwnerAdmin)
|
||||||
|
|
||||||
|
admin.site.register(ApiToken, ApiTokenAdmin)
|
||||||
|
@ -1,21 +1,23 @@
|
|||||||
"""DRF API definition for the 'users' app"""
|
"""DRF API definition for the 'users' app"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import permissions, status
|
from rest_framework import exceptions, permissions
|
||||||
from rest_framework.authtoken.models import Token
|
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from InvenTree.filters import InvenTreeSearchFilter
|
from InvenTree.filters import InvenTreeSearchFilter
|
||||||
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI
|
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI
|
||||||
from InvenTree.serializers import UserSerializer
|
from InvenTree.serializers import UserSerializer
|
||||||
from users.models import Owner, RuleSet, check_user_role
|
from users.models import ApiToken, Owner, RuleSet, check_user_role
|
||||||
from users.serializers import GroupSerializer, OwnerSerializer
|
from users.serializers import GroupSerializer, OwnerSerializer
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class OwnerList(ListAPI):
|
class OwnerList(ListAPI):
|
||||||
"""List API endpoint for Owner model.
|
"""List API endpoint for Owner model.
|
||||||
@ -187,25 +189,34 @@ class GetAuthToken(APIView):
|
|||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Return an API token if the user is authenticated
|
"""Return an API token if the user is authenticated
|
||||||
|
|
||||||
- If the user already has a token, return it
|
- If the user already has a matching token, delete it and create a new one
|
||||||
- Otherwise, create a new token
|
- Existing tokens are *never* exposed again via the API
|
||||||
|
- Once the token is provided, it can be used for auth until it expires
|
||||||
"""
|
"""
|
||||||
if request.user.is_authenticated:
|
|
||||||
# Get the user token (or create one if it does not exist)
|
|
||||||
token, created = Token.objects.get_or_create(user=request.user)
|
|
||||||
return Response({
|
|
||||||
'token': token.key,
|
|
||||||
})
|
|
||||||
|
|
||||||
def delete(self, request):
|
if request.user.is_authenticated:
|
||||||
"""User has requested deletion of API token"""
|
|
||||||
try:
|
user = request.user
|
||||||
request.user.auth_token.delete()
|
name = request.query_params.get('name', '')
|
||||||
return Response({"success": "Successfully logged out."},
|
|
||||||
status=status.HTTP_202_ACCEPTED)
|
# Delete any matching tokens
|
||||||
except (AttributeError, ObjectDoesNotExist):
|
ApiToken.objects.filter(user=user, name=name).delete()
|
||||||
return Response({"error": "Bad request"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST)
|
# User is authenticated, and requesting a token against the provided name.
|
||||||
|
token = ApiToken.objects.create(user=request.user, name=name)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'token': token.key,
|
||||||
|
'name': token.name,
|
||||||
|
'expiry': token.expiry,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Created new API token for user '%s' (name='%s')", user.username, name)
|
||||||
|
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise exceptions.NotAuthenticated()
|
||||||
|
|
||||||
|
|
||||||
user_urls = [
|
user_urls = [
|
||||||
|
32
InvenTree/users/authentication.py
Normal file
32
InvenTree/users/authentication.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""Custom token authentication class for InvenTree API"""
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from rest_framework import exceptions
|
||||||
|
from rest_framework.authentication import TokenAuthentication
|
||||||
|
|
||||||
|
from users.models import ApiToken
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenAuthentication(TokenAuthentication):
|
||||||
|
"""Custom implementation of TokenAuthentication class, with custom features:
|
||||||
|
|
||||||
|
- Tokens can be revoked
|
||||||
|
- Tokens can expire
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = ApiToken
|
||||||
|
|
||||||
|
def authenticate_credentials(self, key):
|
||||||
|
"""Adds additional checks to the default token authentication method."""
|
||||||
|
|
||||||
|
# If this runs without error, then the token is valid (so far)
|
||||||
|
(user, token) = super().authenticate_credentials(key)
|
||||||
|
|
||||||
|
if token.revoked:
|
||||||
|
raise exceptions.AuthenticationFailed(_("Token has been revoked"))
|
||||||
|
|
||||||
|
if token.expired:
|
||||||
|
raise exceptions.AuthenticationFailed(_("Token has expired"))
|
||||||
|
|
||||||
|
return (user, token)
|
35
InvenTree/users/migrations/0008_apitoken.py
Normal file
35
InvenTree/users/migrations/0008_apitoken.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 3.2.22 on 2023-10-20 01:09
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import users.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('users', '0007_alter_ruleset_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ApiToken',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||||
|
('key', models.CharField(db_index=True, default=users.models.default_token, max_length=100, unique=True, verbose_name='Key')),
|
||||||
|
('name', models.CharField(blank=True, help_text='Custom token name', max_length=100, verbose_name='Token Name')),
|
||||||
|
('expiry', models.DateField(default=users.models.default_token_expiry, help_text='Token expiry date', verbose_name='Expiry Date')),
|
||||||
|
('revoked', models.BooleanField(default=False, help_text='Token has been revoked', verbose_name='Revoked')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'API Token',
|
||||||
|
'verbose_name_plural': 'API Tokens',
|
||||||
|
'abstract': False,
|
||||||
|
'unique_together': {('user', 'name')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -1,7 +1,10 @@
|
|||||||
"""Database model definitions for the 'users' app"""
|
"""Database model definitions for the 'users' app"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import admin
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group, Permission
|
from django.contrib.auth.models import Group, Permission
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
@ -15,11 +18,115 @@ from django.dispatch import receiver
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from rest_framework.authtoken.models import Token as AuthToken
|
||||||
|
|
||||||
from InvenTree.ready import canAppAccessDatabase
|
from InvenTree.ready import canAppAccessDatabase
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
logger = logging.getLogger("inventree")
|
||||||
|
|
||||||
|
|
||||||
|
def default_token():
|
||||||
|
"""Generate a default value for the token"""
|
||||||
|
return ApiToken.generate_key()
|
||||||
|
|
||||||
|
|
||||||
|
def default_token_expiry():
|
||||||
|
"""Generate an expiry date for a newly created token"""
|
||||||
|
|
||||||
|
# TODO: Custom value for default expiry timeout
|
||||||
|
# TODO: For now, tokens last for 1 year
|
||||||
|
return datetime.datetime.now().date() + datetime.timedelta(days=365)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiToken(AuthToken):
|
||||||
|
"""Extends the default token model provided by djangorestframework.authtoken, as follows:
|
||||||
|
|
||||||
|
- Adds an 'expiry' date - tokens can be set to expire after a certain date
|
||||||
|
- Adds a 'name' field - tokens can be given a custom name (in addition to the user information)
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass defines model properties"""
|
||||||
|
verbose_name = _('API Token')
|
||||||
|
verbose_name_plural = _('API Tokens')
|
||||||
|
abstract = False
|
||||||
|
unique_together = [
|
||||||
|
('user', 'name')
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""String representation uses the redacted token"""
|
||||||
|
return self.token
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_key(cls, prefix='inv-'):
|
||||||
|
"""Generate a new token key - with custom prefix"""
|
||||||
|
|
||||||
|
# Suffix is the date of creation
|
||||||
|
suffix = '-' + str(datetime.datetime.now().date().isoformat().replace('-', ''))
|
||||||
|
|
||||||
|
return prefix + str(AuthToken.generate_key()) + suffix
|
||||||
|
|
||||||
|
# Override the 'key' field - force it to be unique
|
||||||
|
key = models.CharField(default=default_token, verbose_name=_('Key'), max_length=100, db_index=True, unique=True)
|
||||||
|
|
||||||
|
# Override the 'user' field, to allow multiple tokens per user
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
verbose_name=_('User'),
|
||||||
|
related_name='api_tokens',
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('Token Name'),
|
||||||
|
help_text=_('Custom token name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expiry = models.DateField(
|
||||||
|
default=default_token_expiry,
|
||||||
|
verbose_name=_('Expiry Date'),
|
||||||
|
help_text=_('Token expiry date'),
|
||||||
|
auto_now=False, auto_now_add=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
revoked = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('Revoked'),
|
||||||
|
help_text=_('Token has been revoked'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
@admin.display(description=_('Token'))
|
||||||
|
def token(self):
|
||||||
|
"""Provide a redacted version of the token.
|
||||||
|
|
||||||
|
The *raw* key value should never be displayed anywhere!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# If the token has not yet been saved, return the raw key
|
||||||
|
if self.pk is None:
|
||||||
|
return self.key
|
||||||
|
|
||||||
|
M = len(self.key) - 20
|
||||||
|
|
||||||
|
return self.key[:8] + '*' * M + self.key[-12:]
|
||||||
|
|
||||||
|
@property
|
||||||
|
@admin.display(boolean=True, description=_('Expired'))
|
||||||
|
def expired(self):
|
||||||
|
"""Test if this token has expired"""
|
||||||
|
return self.expiry is not None and self.expiry < datetime.datetime.now().date()
|
||||||
|
|
||||||
|
@property
|
||||||
|
@admin.display(boolean=True, description=_('Active'))
|
||||||
|
def active(self):
|
||||||
|
"""Test if this token is active"""
|
||||||
|
return not self.revoked and not self.expired
|
||||||
|
|
||||||
|
|
||||||
class RuleSet(models.Model):
|
class RuleSet(models.Model):
|
||||||
"""A RuleSet is somewhat like a superset of the django permission class, in that in encapsulates a bunch of permissions.
|
"""A RuleSet is somewhat like a superset of the django permission class, in that in encapsulates a bunch of permissions.
|
||||||
|
|
||||||
@ -58,8 +165,7 @@ class RuleSet(models.Model):
|
|||||||
'auth_group',
|
'auth_group',
|
||||||
'auth_user',
|
'auth_user',
|
||||||
'auth_permission',
|
'auth_permission',
|
||||||
'authtoken_token',
|
'users_apitoken',
|
||||||
'authtoken_tokenproxy',
|
|
||||||
'users_ruleset',
|
'users_ruleset',
|
||||||
'report_reportasset',
|
'report_reportasset',
|
||||||
'report_reportsnippet',
|
'report_reportsnippet',
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
"""API tests for various user / auth API endpoints"""
|
"""API tests for various user / auth API endpoints"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
|
from users.models import ApiToken
|
||||||
|
|
||||||
|
|
||||||
class UserAPITests(InvenTreeAPITestCase):
|
class UserAPITests(InvenTreeAPITestCase):
|
||||||
@ -51,3 +54,82 @@ class UserAPITests(InvenTreeAPITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn('name', response.data)
|
self.assertIn('name', response.data)
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenTests(InvenTreeAPITestCase):
|
||||||
|
"""Tests for user token functionality"""
|
||||||
|
|
||||||
|
def test_token_generation(self):
|
||||||
|
"""Test user token generation"""
|
||||||
|
|
||||||
|
url = reverse('api-token')
|
||||||
|
|
||||||
|
self.assertEqual(ApiToken.objects.count(), 0)
|
||||||
|
|
||||||
|
# Generate multiple tokens with different names
|
||||||
|
for name in ['cat', 'dog', 'biscuit']:
|
||||||
|
data = self.get(url, data={'name': name}, expected_code=200).data
|
||||||
|
|
||||||
|
self.assertTrue(data['token'].startswith('inv-'))
|
||||||
|
self.assertEqual(data['name'], name)
|
||||||
|
|
||||||
|
# Check that the tokens were created
|
||||||
|
self.assertEqual(ApiToken.objects.count(), 3)
|
||||||
|
|
||||||
|
# If we re-generate a token, the value changes
|
||||||
|
token = ApiToken.objects.filter(name='cat').first()
|
||||||
|
|
||||||
|
# Request a *new* token with the same name
|
||||||
|
data = self.get(url, data={'name': 'cat'}, expected_code=200).data
|
||||||
|
|
||||||
|
self.assertNotEqual(data['token'], token.key)
|
||||||
|
|
||||||
|
# Check the old token is deleted
|
||||||
|
self.assertEqual(ApiToken.objects.count(), 3)
|
||||||
|
with self.assertRaises(ApiToken.DoesNotExist):
|
||||||
|
token.refresh_from_db()
|
||||||
|
|
||||||
|
def test_token_auth(self):
|
||||||
|
"""Test user token authentication"""
|
||||||
|
|
||||||
|
# Create a new token
|
||||||
|
token_key = self.get(url=reverse('api-token'), data={'name': 'test'}, expected_code=200).data['token']
|
||||||
|
|
||||||
|
# Check that we can use the token to authenticate
|
||||||
|
self.client.logout()
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token_key)
|
||||||
|
|
||||||
|
me = reverse('api-user-me')
|
||||||
|
|
||||||
|
response = self.client.get(me, expected_code=200)
|
||||||
|
|
||||||
|
# Grab the token, and update
|
||||||
|
token = ApiToken.objects.first()
|
||||||
|
self.assertEqual(token.key, token_key)
|
||||||
|
|
||||||
|
# Revoke the token
|
||||||
|
token.revoked = True
|
||||||
|
token.save()
|
||||||
|
|
||||||
|
self.assertFalse(token.active)
|
||||||
|
|
||||||
|
response = self.client.get(me, expected_code=401)
|
||||||
|
self.assertIn('Token has been revoked', str(response.data))
|
||||||
|
|
||||||
|
# Expire the token
|
||||||
|
token.revoked = False
|
||||||
|
token.expiry = datetime.datetime.now().date() - datetime.timedelta(days=10)
|
||||||
|
token.save()
|
||||||
|
|
||||||
|
self.assertTrue(token.expired)
|
||||||
|
self.assertFalse(token.active)
|
||||||
|
|
||||||
|
response = self.client.get(me, expected_code=401)
|
||||||
|
self.assertIn('Token has expired', str(response.data))
|
||||||
|
|
||||||
|
# Re-enable the token
|
||||||
|
token.revoked = False
|
||||||
|
token.expiry = datetime.datetime.now().date() + datetime.timedelta(days=10)
|
||||||
|
token.save()
|
||||||
|
|
||||||
|
self.client.get(me, expected_code=200)
|
||||||
|
@ -5,10 +5,8 @@ from django.contrib.auth.models import Group
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from rest_framework.authtoken.models import Token
|
|
||||||
|
|
||||||
from InvenTree.unit_test import InvenTreeTestCase
|
from InvenTree.unit_test import InvenTreeTestCase
|
||||||
from users.models import Owner, RuleSet
|
from users.models import ApiToken, Owner, RuleSet
|
||||||
|
|
||||||
|
|
||||||
class RuleSetModelTest(TestCase):
|
class RuleSetModelTest(TestCase):
|
||||||
@ -242,7 +240,7 @@ class OwnerModelTest(InvenTreeTestCase):
|
|||||||
"""Test token mechanisms."""
|
"""Test token mechanisms."""
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
|
|
||||||
token = Token.objects.filter(user=self.user)
|
token = ApiToken.objects.filter(user=self.user)
|
||||||
|
|
||||||
# not authed
|
# not authed
|
||||||
self.do_request(reverse('api-token'), {}, 401)
|
self.do_request(reverse('api-token'), {}, 401)
|
||||||
@ -252,15 +250,6 @@ class OwnerModelTest(InvenTreeTestCase):
|
|||||||
response = self.do_request(reverse('api-token'), {})
|
response = self.do_request(reverse('api-token'), {})
|
||||||
self.assertEqual(response['token'], token.first().key)
|
self.assertEqual(response['token'], token.first().key)
|
||||||
|
|
||||||
# token delete
|
|
||||||
response = self.client.delete(reverse('api-token'), {}, format='json')
|
|
||||||
self.assertEqual(response.status_code, 202)
|
|
||||||
self.assertEqual(len(token), 0)
|
|
||||||
|
|
||||||
# token second delete
|
|
||||||
response = self.client.delete(reverse('api-token'), {}, format='json')
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
|
|
||||||
# test user is associated with token
|
# test user is associated with token
|
||||||
response = self.do_request(reverse('api-user-me'), {}, 200)
|
response = self.do_request(reverse('api-user-me'), {'name': 'another-token'}, 200)
|
||||||
self.assertEqual(response['username'], self.username)
|
self.assertEqual(response['username'], self.username)
|
||||||
|
@ -81,12 +81,12 @@ export function NotificationDrawer({
|
|||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
<Divider />
|
<Divider />
|
||||||
<LoadingOverlay visible={notificationQuery.isFetching} />
|
<LoadingOverlay visible={notificationQuery.isFetching} />
|
||||||
{notificationQuery.data?.results?.length == 0 && (
|
{(notificationQuery.data?.results?.length ?? 0) == 0 && (
|
||||||
<Alert color="green">
|
<Alert color="green">
|
||||||
<Text size="sm">{t`You have no unread notifications.`}</Text>
|
<Text size="sm">{t`You have no unread notifications.`}</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{notificationQuery.data?.results.map((notification: any) => (
|
{notificationQuery.data?.results?.map((notification: any) => (
|
||||||
<Group position="apart">
|
<Group position="apart">
|
||||||
<Stack spacing="3">
|
<Stack spacing="3">
|
||||||
<Text size="sm">{notification.target?.name ?? 'target'}</Text>
|
<Text size="sm">{notification.target?.name ?? 'target'}</Text>
|
||||||
|
@ -21,7 +21,10 @@ export const doClassicLogin = async (username: string, password: string) => {
|
|||||||
.get(apiUrl(ApiPaths.user_token), {
|
.get(apiUrl(ApiPaths.user_token), {
|
||||||
auth: { username, password },
|
auth: { username, password },
|
||||||
baseURL: host.toString(),
|
baseURL: host.toString(),
|
||||||
timeout: 5000
|
timeout: 5000,
|
||||||
|
params: {
|
||||||
|
name: 'inventree-web-app'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then((response) => response.data.token)
|
.then((response) => response.data.token)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@ -114,7 +117,10 @@ export function handleReset(navigate: any, values: { email: string }) {
|
|||||||
export function checkLoginState(navigate: any, redirect?: string) {
|
export function checkLoginState(navigate: any, redirect?: string) {
|
||||||
api
|
api
|
||||||
.get(apiUrl(ApiPaths.user_token), {
|
.get(apiUrl(ApiPaths.user_token), {
|
||||||
timeout: 5000
|
timeout: 5000,
|
||||||
|
params: {
|
||||||
|
name: 'inventree-web-app'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then((val) => {
|
.then((val) => {
|
||||||
if (val.status === 200 && val.data.token) {
|
if (val.status === 200 && val.data.token) {
|
||||||
|
2
tasks.py
2
tasks.py
@ -35,7 +35,7 @@ def content_excludes():
|
|||||||
excludes = [
|
excludes = [
|
||||||
"contenttypes",
|
"contenttypes",
|
||||||
"auth.permission",
|
"auth.permission",
|
||||||
"authtoken.token",
|
"users.apitoken",
|
||||||
"error_report.error",
|
"error_report.error",
|
||||||
"admin.logentry",
|
"admin.logentry",
|
||||||
"django_q.schedule",
|
"django_q.schedule",
|
||||||
|
Loading…
Reference in New Issue
Block a user