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

Api token updates #5664

Merged
merged 28 commits into from
Oct 20, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
44e1d38
Create new APIToken model
SchrodingersGat Oct 4, 2023
6e025b6
Add data migration to port across any existing user tokens
SchrodingersGat Oct 4, 2023
c2b4095
Adds 'revoked' field - tokens can be manually revoked
SchrodingersGat Oct 4, 2023
eff6e6a
Update API token - allow multiple tokens per user
SchrodingersGat Oct 4, 2023
0d4c595
Custom token auth handler
SchrodingersGat Oct 5, 2023
b5f74ab
Update AuthRequiredMiddleware
SchrodingersGat Oct 5, 2023
38ba505
Token API endpoint improvements
SchrodingersGat Oct 17, 2023
ddfc563
Merge remote-tracking branch 'inventree/master' into api-token-updates
SchrodingersGat Oct 19, 2023
91a9cc0
Consolidate migrations
SchrodingersGat Oct 19, 2023
d59d5da
When requesting a token, overwrite inactive token for authenticated user
SchrodingersGat Oct 19, 2023
5965553
Fix
SchrodingersGat Oct 19, 2023
b45e640
Use token name for frontend
SchrodingersGat Oct 19, 2023
d4428e7
Force token expiry, and generate default expiry date
SchrodingersGat Oct 19, 2023
089b19d
Force generation of a new token when requested
SchrodingersGat Oct 19, 2023
c4a2f74
Reduce data exposed on token API endpoint
SchrodingersGat Oct 19, 2023
1636cd4
Display redacted token in admin site
SchrodingersGat Oct 19, 2023
459d835
Merge remote-tracking branch 'inventree/master' into api-token-updates
SchrodingersGat Oct 19, 2023
33df61e
Log when new token is created for user
SchrodingersGat Oct 19, 2023
46cc71b
Add default value for token
SchrodingersGat Oct 19, 2023
8c4b1c9
Fixes for admin interface
SchrodingersGat Oct 19, 2023
b8e64ed
Implement unit tests for token functionality
SchrodingersGat Oct 19, 2023
3b5f3be
Fix content exclude for import/export
SchrodingersGat Oct 20, 2023
1c82920
Fix typo
SchrodingersGat Oct 20, 2023
29c5700
Further tweaks
SchrodingersGat Oct 20, 2023
e3b7246
Longer token requires longer database field!
SchrodingersGat Oct 20, 2023
bf25344
Fix other API tokens
SchrodingersGat Oct 20, 2023
243a68c
Remove 'delete' method from token API endpoint
SchrodingersGat Oct 20, 2023
73682c3
Bump API version
SchrodingersGat Oct 20, 2023
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
6 changes: 3 additions & 3 deletions InvenTree/users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
class ApiTokenAdmin(admin.ModelAdmin):
"""Admin class for the ApiToken model."""

list_display = ('key', 'user', 'name', 'expiry', 'active')
fields = ('user', 'name', 'revoked', 'expiry')
readonly_fields = ('key', 'created')
list_display = ('token', 'user', 'name', 'expiry', 'active')
fields = ('token', 'name', 'revoked', 'expiry')
readonly_fields = ('token', 'created')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Why is that changed? The key is normally used for identification in systems like this

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "token" is the redacted version, once it is created the raw value is not shown in the admin interface - I believe this is what you requested?



class RuleSetInline(admin.TabularInline):
Expand Down
34 changes: 25 additions & 9 deletions InvenTree/users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
from django.urls import include, path, re_path

from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import permissions, status
from rest_framework.authtoken.models import Token
from rest_framework import exceptions, permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView

from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI
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


Expand Down Expand Up @@ -187,15 +186,32 @@ class GetAuthToken(APIView):
def get(self, request, *args, **kwargs):
"""Return an API token if the user is authenticated

- If the user already has a token, return it
- Otherwise, create a new token
- If the user already has a matching token, delete it and create a new one
- 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({

user = request.user
name = request.query_params.get('name', '')

# Delete any matching tokens
ApiToken.objects.filter(user=user, name=name).delete()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment: I think it would be prudent to log it a token was actually deleted for development (maybe log the identifier)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call I'll add that.


# 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,
}

return Response(data)

else:
raise exceptions.NotAuthenticated()

def delete(self, request):
"""User has requested deletion of API token"""
Expand Down
9 changes: 6 additions & 3 deletions InvenTree/users/migrations/0008_apitoken.py
matmair marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Generated by Django 3.2.21 on 2023-10-04 13:52
# Generated by Django 3.2.22 on 2023-10-19 13:31

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import users.models


class Migration(migrations.Migration):
Expand All @@ -17,16 +18,18 @@ class Migration(migrations.Migration):
name='ApiToken',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=40, db_index=True, unique=True, verbose_name='Key')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('key', models.CharField(db_index=True, max_length=40, 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(blank=True, help_text='Token expiry date', null=True, verbose_name='Expiry Date')),
('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')},
},
),
]
19 changes: 0 additions & 19 deletions InvenTree/users/migrations/0009_alter_apitoken_unique_together.py

This file was deleted.

44 changes: 0 additions & 44 deletions InvenTree/users/migrations/0010_auto_20231004_1355.py

This file was deleted.

18 changes: 0 additions & 18 deletions InvenTree/users/migrations/0011_apitoken_revoked.py

This file was deleted.

27 changes: 26 additions & 1 deletion InvenTree/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
logger = logging.getLogger("inventree")


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:

Expand All @@ -41,6 +49,10 @@ class Meta:
('user', 'name')
]

def __str__(self):
"""String representation uses the redacted token"""
return self.token

# Override the 'key' field - force it to be unique
key = models.CharField(verbose_name=_('Key'), max_length=40, db_index=True, unique=True)

Expand All @@ -60,7 +72,7 @@ class Meta:
)

expiry = models.DateField(
blank=True, null=True,
default=default_token_expiry,
verbose_name=_('Expiry Date'),
help_text=_('Token expiry date'),
auto_now=False, auto_now_add=False,
Expand All @@ -72,6 +84,19 @@ class Meta:
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!
"""

N = 8
M = len(self.key) - N

return self.key[:N] + '*' * M

@property
@admin.display(boolean=True, description=_('Expired'))
def expired(self):
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/src/components/nav/NotificationDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ export function NotificationDrawer({
<Stack spacing="xs">
<Divider />
<LoadingOverlay visible={notificationQuery.isFetching} />
{notificationQuery.data?.results?.length == 0 && (
{(notificationQuery.data?.results?.length ?? 0) == 0 && (
<Alert color="green">
<Text size="sm">{t`You have no unread notifications.`}</Text>
</Alert>
)}
{notificationQuery.data?.results.map((notification: any) => (
{notificationQuery.data?.results?.map((notification: any) => (
<Group position="apart">
<Stack spacing="3">
<Text size="sm">{notification.target?.name ?? 'target'}</Text>
Expand Down
10 changes: 8 additions & 2 deletions src/frontend/src/functions/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export const doClassicLogin = async (username: string, password: string) => {
.get(apiUrl(ApiPaths.user_token), {
auth: { username, password },
baseURL: host.toString(),
timeout: 5000
timeout: 5000,
params: {
name: 'inventree-web-app'
}
})
.then((response) => response.data.token)
.catch((error) => {
Expand Down Expand Up @@ -114,7 +117,10 @@ export function handleReset(navigate: any, values: { email: string }) {
export function checkLoginState(navigate: any, redirect?: string) {
api
.get(apiUrl(ApiPaths.user_token), {
timeout: 5000
timeout: 5000,
params: {
name: 'inventree-web-app'
}
})
.then((val) => {
if (val.status === 200 && val.data.token) {
Expand Down
Loading