mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-10 10:57:52 +00:00
feat: add notification on token issuance (#13141)
* add notification on token issuance * add entries * fix link url * increate test timing * ignore fallback line * add test for notification
This commit is contained in:
1 parent
daa1c304f4
commit
df7f9521d7
10 files changed
+177
-7
No files matched your search
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- [#13141](https://github.com/inventree/InvenTree/pull/13141) Adds a notification when a token is issued and warns if that notification can not be send
|
||||
- [#13069](https://github.com/inventree/InvenTree/pull/13069) Adds an audit log which tracks changes made to internal data and exposes read-only API endpoints (/api/audit/) for accessing the audit log.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 567
|
||||
INVENTREE_API_VERSION = 568
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v568 -> 2026-10-30 : https://github.com/inventree/InvenTree/pull/13141
|
||||
- token endpoints now have a warning if notifications cannot be sent
|
||||
|
||||
v567 -> 2026-10-28 : https://github.com/inventree/InvenTree/pull/13128
|
||||
- Allow ordering of SupplierPart API by "on_order" field
|
||||
|
||||
|
||||
@@ -867,7 +867,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
||||
self.assertEqual('InvenTree', data['server'])
|
||||
|
||||
# Test with token
|
||||
token = self.get(url=reverse('api-token'), max_query_count=20).data['token']
|
||||
token = self.get(url=reverse('api-token'), max_query_count=40).data['token']
|
||||
self.client.logout()
|
||||
|
||||
# Anon
|
||||
@@ -878,7 +878,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
||||
|
||||
# Staff
|
||||
response = self.get(
|
||||
url, headers={'Authorization': f'Token {token}'}, max_query_count=20
|
||||
url, headers={'Authorization': f'Token {token}'}, max_query_count=40
|
||||
)
|
||||
self.assertIsNotNone(data.get('active_plugins'))
|
||||
self.assertGreater(len(response.json()['database']), 4)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "email/email.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block title %}
|
||||
{{ message }}
|
||||
<p>{% trans "If you did not request this token, revoke it immediately and contact your administrator." %}</p>
|
||||
{% if link %}
|
||||
<p>{% trans "Click on the following link to review your API tokens" %}: <a href="{{ link }}">{{ link }}</a></p>
|
||||
{% endif %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block body %}
|
||||
<tr style="height: 3rem; border-bottom: 1px solid">
|
||||
<th>{% trans "Token Name" %}</th>
|
||||
<th>{% trans "Issued By" %}</th>
|
||||
<th>{% trans "Expiry Date" %}</th>
|
||||
</tr>
|
||||
|
||||
<tr style="height: 3rem">
|
||||
<td style="text-align: center;">{{ token.name }}</td>
|
||||
<td style="text-align: center;">{{ token.issued_by|default:"-" }}</td>
|
||||
<td style="text-align: center;">{{ token.expiry }}</td>
|
||||
</tr>
|
||||
|
||||
{% endblock body %}
|
||||
@@ -36,7 +36,14 @@ from InvenTree.mixins import (
|
||||
)
|
||||
from InvenTree.schema import exclude_from_schema
|
||||
from InvenTree.settings import FRONTEND_URL_BASE
|
||||
from users.models import ApiToken, Owner, RuleSet, TipResult, UserProfile
|
||||
from users.models import (
|
||||
ApiToken,
|
||||
Owner,
|
||||
RuleSet,
|
||||
TipResult,
|
||||
UserProfile,
|
||||
check_email_warning,
|
||||
)
|
||||
from users.serializers import (
|
||||
ApiTokenSerializer,
|
||||
ExtendedUserSerializer,
|
||||
@@ -441,6 +448,9 @@ class GetAuthToken(GenericAPIView):
|
||||
'expiry': token.expiry,
|
||||
}
|
||||
|
||||
if warning := check_email_warning():
|
||||
data['warning'] = warning
|
||||
|
||||
# Ensure that the users session is logged in
|
||||
if not get_user(request).is_authenticated:
|
||||
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
|
||||
@@ -513,6 +523,10 @@ class TokenListView(TokenMixin, ListCreateAPI):
|
||||
"""Create token and show key to user."""
|
||||
resp = super().create(request, *args, **kwargs)
|
||||
resp.data['token'] = self._created_token.token
|
||||
|
||||
if warning := check_email_warning():
|
||||
resp.data['warning'] = warning
|
||||
|
||||
return resp
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
@@ -385,6 +385,25 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin):
|
||||
return not self.revoked and not self.expired
|
||||
|
||||
|
||||
def check_email_warning():
|
||||
"""Check if email is configured and return a warning if not."""
|
||||
from InvenTree.helpers_email import is_email_configured
|
||||
|
||||
if is_email_configured():
|
||||
return None # pragma: no cover
|
||||
return _('Email is not configured on this server - this is a security risk.')
|
||||
|
||||
|
||||
@receiver(post_save, sender=ApiToken, dispatch_uid='api_token_notification')
|
||||
def api_token_notification(sender, instance: ApiToken, created, **kwargs):
|
||||
"""Notify the token owner that a token was made for them."""
|
||||
if not created or isImportingData() or isReadOnlyCommand():
|
||||
return
|
||||
from users.tasks import notify_api_token
|
||||
|
||||
notify_api_token(instance)
|
||||
|
||||
|
||||
class RuleSet(models.Model):
|
||||
"""A RuleSet is somewhat like a superset of the django permission class, in that in encapsulates a bunch of permissions.
|
||||
|
||||
|
||||
@@ -142,11 +142,12 @@ class GetAuthTokenSerializer(serializers.Serializer):
|
||||
"""Meta options for GetAuthTokenSerializer."""
|
||||
|
||||
model = ApiToken
|
||||
fields = ['token', 'name', 'expiry']
|
||||
fields = ['token', 'name', 'expiry', 'warning']
|
||||
|
||||
token = serializers.CharField(read_only=True)
|
||||
name = serializers.CharField()
|
||||
expiry = serializers.DateField(read_only=True)
|
||||
warning = serializers.CharField(read_only=True, required=False)
|
||||
|
||||
|
||||
class BriefUserProfileSerializer(InvenTreeModelSerializer):
|
||||
|
||||
@@ -4,17 +4,60 @@ from typing import Any
|
||||
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import structlog
|
||||
|
||||
import common.notifications
|
||||
import InvenTree.helpers
|
||||
from InvenTree.helpers_model import construct_absolute_url
|
||||
from InvenTree.ready import canAppAccessDatabase
|
||||
from users.models import RuleSet
|
||||
from users.models import ApiToken, RuleSet, check_email_warning
|
||||
from users.permissions import get_model_permission_string, split_permission
|
||||
from users.ruleset import RULESET_CHANGE_INHERIT, RULESET_CHOICES, RULESET_NAMES
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
def notify_api_token(token: ApiToken):
|
||||
"""Send warning that a token has been issued."""
|
||||
user = token.user
|
||||
issuer = token.issued_by
|
||||
|
||||
if issuer and issuer != user:
|
||||
message = _(
|
||||
f"A new API token '{token.name}' was issued for your account by {issuer.username}"
|
||||
)
|
||||
else:
|
||||
message = _(f"A new API token '{token.name}' was issued for your account")
|
||||
|
||||
name = _('API Token Issued')
|
||||
context = {
|
||||
'name': name,
|
||||
'message': message,
|
||||
'token': token,
|
||||
'link': construct_absolute_url(
|
||||
InvenTree.helpers.pui_url('settings/user/security')
|
||||
),
|
||||
'template': {'html': 'email/api_token_issued.html', 'subject': name},
|
||||
}
|
||||
|
||||
if check_email_warning():
|
||||
logger.warning(
|
||||
"INVE-W7: API token '%s' issued for user '%s' - but email not set up",
|
||||
token.name,
|
||||
user.username,
|
||||
)
|
||||
common.notifications.trigger_notification(
|
||||
None,
|
||||
'users.api_token_issued',
|
||||
targets=[user],
|
||||
context=context,
|
||||
check_recent=False,
|
||||
notification_uid=f'api_token_{token.pk}',
|
||||
)
|
||||
|
||||
|
||||
def rebuild_all_permissions() -> None:
|
||||
"""Rebuild all user permissions.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
from common.models import NotificationMessage
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from users.models import ApiToken, default_token
|
||||
from users.ruleset import RULESET_NAMES, get_ruleset_models
|
||||
@@ -502,6 +503,53 @@ class UserTokenTests(InvenTreeAPITestCase):
|
||||
for k in keys:
|
||||
self.assertIn(k, token.metadata)
|
||||
|
||||
def test_token_issued_notification(self):
|
||||
"""A notification is sent whenever a token is issued, with a warning if email is unavailable."""
|
||||
self.ensurePluginsLoaded()
|
||||
|
||||
url = reverse('api-token')
|
||||
category = 'users.api_token_issued'
|
||||
|
||||
self.assertEqual(
|
||||
NotificationMessage.objects.filter(category=category).count(), 0
|
||||
)
|
||||
|
||||
# No email configured -> warning is returned, UI notification is still created
|
||||
with self.assertLogs('inventree', level='WARNING') as logs:
|
||||
data = self.get(url, data={'name': 'notify'}, expected_code=200).data
|
||||
|
||||
self.assertIn('warning', data)
|
||||
self.assertIn('Email is not configured', data['warning'])
|
||||
self.assertTrue(any('INVE-W7' in msg for msg in logs.output))
|
||||
|
||||
msgs = NotificationMessage.objects.filter(category=category, user=self.user)
|
||||
self.assertEqual(msgs.count(), 1)
|
||||
self.assertIn("'notify'", msgs.first().message)
|
||||
|
||||
# Re-issuing a token with the same name sends another notification
|
||||
self.get(url, data={'name': 'notify'}, expected_code=200)
|
||||
self.assertEqual(msgs.count(), 2)
|
||||
|
||||
# Email configured -> no warning
|
||||
with self.settings(EMAIL_HOST='localhost', TESTING_BYPASS_MAILCHECK=True):
|
||||
data = self.get(url, data={'name': 'mailed'}, expected_code=200).data
|
||||
self.assertNotIn('warning', data)
|
||||
|
||||
# Superuser issuing a token for another user notifies that user
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
other = User.objects.exclude(pk=self.user.pk).first()
|
||||
|
||||
data = self.post(
|
||||
reverse('api-token-list'),
|
||||
data={'name': 'for-other', 'user': other.pk},
|
||||
expected_code=201,
|
||||
).data
|
||||
self.assertIn('warning', data)
|
||||
|
||||
msg = NotificationMessage.objects.get(category=category, user=other)
|
||||
self.assertIn(self.user.username, msg.message)
|
||||
|
||||
def test_token_auth(self):
|
||||
"""Test user token authentication."""
|
||||
# Create a new token
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { TableFilter } from '@lib/types/Filters';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
Textarea
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconCircleX } from '@tabler/icons-react';
|
||||
import { IconAlertTriangle, IconCircleX } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { api } from '../../App';
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ export function ApiTokenTable({
|
||||
only_myself = true
|
||||
}: Readonly<{ only_myself: boolean }>) {
|
||||
const [token, setToken] = useState<string>('');
|
||||
const [tokenWarning, setTokenWarning] = useState<string>('');
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [revokeTokenId, setRevokeTokenId] = useState<string | null>(null);
|
||||
const [revocationReason, setRevocationReason] = useState<string>('');
|
||||
@@ -48,6 +50,7 @@ export function ApiTokenTable({
|
||||
successMessage: t`Token generated`,
|
||||
onFormSuccess: (data: any) => {
|
||||
setToken(data.token);
|
||||
setTokenWarning(data.warning ?? '');
|
||||
open();
|
||||
table.refreshTable();
|
||||
}
|
||||
@@ -233,6 +236,17 @@ export function ApiTokenTable({
|
||||
Tokens are only shown once - make sure to note it down.
|
||||
</Trans>
|
||||
</Text>
|
||||
{tokenWarning && (
|
||||
<Alert
|
||||
color='red'
|
||||
variant='filled'
|
||||
icon={<IconAlertTriangle />}
|
||||
title={t`Notification could not be sent`}
|
||||
data-testid='api-token-email-warning'
|
||||
>
|
||||
{tokenWarning}
|
||||
</Alert>
|
||||
)}
|
||||
<Paper p='sm'>
|
||||
<Flex>
|
||||
<Code>{token}</Code>
|
||||
|
||||
Reference in new issue
Block a user