mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-09 13:22:18 -06:00
Compare commits
6 Commits
922a611282
...
cb5119511c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb5119511c | ||
|
|
7bca9f5d6d | ||
|
|
502b33b144 | ||
|
|
10e69c8b30 | ||
|
|
513b11450d | ||
|
|
b5edfa5d53 |
@@ -25,10 +25,12 @@ from extras.models import Bookmark
|
|||||||
from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
|
from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
|
||||||
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
|
from netbox.ui import layout
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from users import forms
|
from users import forms
|
||||||
from users.models import UserConfig
|
from users.models import UserConfig
|
||||||
from users.tables import TokenTable
|
from users.tables import TokenTable
|
||||||
|
from users.ui.panels import TokenExamplePanel, TokenPanel
|
||||||
from utilities.request import safe_for_redirect
|
from utilities.request import safe_for_redirect
|
||||||
from utilities.string import remove_linebreaks
|
from utilities.string import remove_linebreaks
|
||||||
from utilities.views import register_model_view
|
from utilities.views import register_model_view
|
||||||
@@ -342,12 +344,21 @@ class UserTokenListView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
@register_model_view(UserToken)
|
@register_model_view(UserToken)
|
||||||
class UserTokenView(LoginRequiredMixin, View):
|
class UserTokenView(LoginRequiredMixin, View):
|
||||||
|
layout = layout.SimpleLayout(
|
||||||
|
left_panels=[
|
||||||
|
TokenPanel(),
|
||||||
|
],
|
||||||
|
right_panels=[
|
||||||
|
TokenExamplePanel(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
|
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
|
||||||
|
|
||||||
return render(request, 'account/token.html', {
|
return render(request, 'account/token.html', {
|
||||||
'object': token,
|
'object': token,
|
||||||
|
'layout': self.layout,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
|||||||
# Match against the directly assigned role as well as any parent roles.
|
# Match against the directly assigned role as well as any parent roles.
|
||||||
device_roles = obj.role.get_ancestors(include_self=True) if obj.role else []
|
device_roles = obj.role.get_ancestors(include_self=True) if obj.role else []
|
||||||
|
|
||||||
|
# Match against the directly assigned platform as well as any parent platforms.
|
||||||
|
platform = getattr(obj, 'platform', None)
|
||||||
|
platforms = platform.get_ancestors(include_self=True) if platform else []
|
||||||
|
|
||||||
queryset = self.filter(
|
queryset = self.filter(
|
||||||
Q(regions__in=regions) | Q(regions=None),
|
Q(regions__in=regions) | Q(regions=None),
|
||||||
Q(site_groups__in=sitegroups) | Q(site_groups=None),
|
Q(site_groups__in=sitegroups) | Q(site_groups=None),
|
||||||
@@ -53,7 +57,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
|||||||
Q(locations__in=locations) | Q(locations=None),
|
Q(locations__in=locations) | Q(locations=None),
|
||||||
Q(device_types=device_type) | Q(device_types=None),
|
Q(device_types=device_type) | Q(device_types=None),
|
||||||
Q(roles__in=device_roles) | Q(roles=None),
|
Q(roles__in=device_roles) | Q(roles=None),
|
||||||
Q(platforms=obj.platform) | Q(platforms=None),
|
Q(platforms__in=platforms) | Q(platforms=None),
|
||||||
Q(cluster_types=cluster_type) | Q(cluster_types=None),
|
Q(cluster_types=cluster_type) | Q(cluster_types=None),
|
||||||
Q(cluster_groups=cluster_group) | Q(cluster_groups=None),
|
Q(cluster_groups=cluster_group) | Q(cluster_groups=None),
|
||||||
Q(clusters=cluster) | Q(clusters=None),
|
Q(clusters=cluster) | Q(clusters=None),
|
||||||
@@ -103,7 +107,6 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
|||||||
"content_type__model": self.model._meta.model_name
|
"content_type__model": self.model._meta.model_name
|
||||||
}
|
}
|
||||||
base_query = Q(
|
base_query = Q(
|
||||||
Q(platforms=OuterRef('platform')) | Q(platforms=None),
|
|
||||||
Q(cluster_types=OuterRef('cluster__type')) | Q(cluster_types=None),
|
Q(cluster_types=OuterRef('cluster__type')) | Q(cluster_types=None),
|
||||||
Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None),
|
Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None),
|
||||||
Q(clusters=OuterRef('cluster')) | Q(clusters=None),
|
Q(clusters=OuterRef('cluster')) | Q(clusters=None),
|
||||||
@@ -167,6 +170,15 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
|||||||
) | Q(roles=None)),
|
) | Q(roles=None)),
|
||||||
Q.AND
|
Q.AND
|
||||||
)
|
)
|
||||||
|
base_query.add(
|
||||||
|
(Q(
|
||||||
|
platforms__tree_id=OuterRef('platform__tree_id'),
|
||||||
|
platforms__level__lte=OuterRef('platform__level'),
|
||||||
|
platforms__lft__lte=OuterRef('platform__lft'),
|
||||||
|
platforms__rght__gte=OuterRef('platform__rght'),
|
||||||
|
) | Q(platforms=None)),
|
||||||
|
Q.AND
|
||||||
|
)
|
||||||
|
|
||||||
return base_query
|
return base_query
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class TokenAuthentication(BaseAuthentication):
|
|||||||
try:
|
try:
|
||||||
auth_value = auth[1].decode()
|
auth_value = auth[1].decode()
|
||||||
except UnicodeError:
|
except UnicodeError:
|
||||||
raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters")
|
raise exceptions.AuthenticationFailed('Invalid authorization header: Token contains invalid characters')
|
||||||
|
|
||||||
# Infer token version from presence or absence of prefix
|
# Infer token version from presence or absence of prefix
|
||||||
version = 2 if auth_value.startswith(TOKEN_PREFIX) else 1
|
version = 2 if auth_value.startswith(TOKEN_PREFIX) else 1
|
||||||
@@ -75,17 +75,21 @@ class TokenAuthentication(BaseAuthentication):
|
|||||||
client_ip = get_client_ip(request)
|
client_ip = get_client_ip(request)
|
||||||
if client_ip is None:
|
if client_ip is None:
|
||||||
raise exceptions.AuthenticationFailed(
|
raise exceptions.AuthenticationFailed(
|
||||||
"Client IP address could not be determined for validation. Check that the HTTP server is "
|
'Client IP address could not be determined for validation. Check that the HTTP server is '
|
||||||
"correctly configured to pass the required header(s)."
|
'correctly configured to pass the required header(s).'
|
||||||
)
|
)
|
||||||
if not token.validate_client_ip(client_ip):
|
if not token.validate_client_ip(client_ip):
|
||||||
raise exceptions.AuthenticationFailed(
|
raise exceptions.AuthenticationFailed(
|
||||||
f"Source IP {client_ip} is not permitted to authenticate using this token."
|
f"Source IP {client_ip} is not permitted to authenticate using this token."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Enforce the Token is enabled
|
||||||
|
if not token.enabled:
|
||||||
|
raise exceptions.AuthenticationFailed('Token disabled')
|
||||||
|
|
||||||
# Enforce the Token's expiration time, if one has been set.
|
# Enforce the Token's expiration time, if one has been set.
|
||||||
if token.is_expired:
|
if token.is_expired:
|
||||||
raise exceptions.AuthenticationFailed("Token expired")
|
raise exceptions.AuthenticationFailed('Token expired')
|
||||||
|
|
||||||
# Update last used, but only once per minute at most. This reduces write load on the database
|
# Update last used, but only once per minute at most. This reduces write load on the database
|
||||||
if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60:
|
if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60:
|
||||||
|
|||||||
@@ -66,6 +66,32 @@ class TokenAuthenticationTestCase(APITestCase):
|
|||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
self.assertEqual(response.data['detail'], "Invalid v2 token")
|
self.assertEqual(response.data['detail'], "Invalid v2 token")
|
||||||
|
|
||||||
|
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_token_enabled(self):
|
||||||
|
url = reverse('dcim-api:site-list')
|
||||||
|
|
||||||
|
# Create v1 & v2 tokens
|
||||||
|
token1 = Token.objects.create(version=1, user=self.user, enabled=True)
|
||||||
|
token2 = Token.objects.create(version=2, user=self.user, enabled=True)
|
||||||
|
|
||||||
|
# Request with an enabled token should succeed
|
||||||
|
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Request with a disabled token should fail
|
||||||
|
token1.enabled = False
|
||||||
|
token1.save()
|
||||||
|
token2.enabled = False
|
||||||
|
token2.save()
|
||||||
|
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(response.data['detail'], 'Token disabled')
|
||||||
|
response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(response.data['detail'], 'Token disabled')
|
||||||
|
|
||||||
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
|
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
def test_token_expiration(self):
|
def test_token_expiration(self):
|
||||||
url = reverse('dcim-api:site-list')
|
url = reverse('dcim-api:site-list')
|
||||||
|
|||||||
3025
netbox/project-static/dist/graphiql/graphiql.min.css
vendored
3025
netbox/project-static/dist/graphiql/graphiql.min.css
vendored
File diff suppressed because it is too large
Load Diff
96214
netbox/project-static/dist/graphiql/graphiql.min.js
vendored
96214
netbox/project-static/dist/graphiql/graphiql.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "netbox-graphiql",
|
"name": "netbox-graphiql",
|
||||||
"version": "4.3.0",
|
"version": "4.5.0",
|
||||||
"description": "NetBox GraphiQL Custom Front End",
|
"description": "NetBox GraphiQL Custom Front End",
|
||||||
"main": "dist/graphiql.js",
|
"main": "dist/graphiql.js",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@graphiql/plugin-explorer": "3.2.5",
|
"@graphiql/plugin-explorer": "3.2.6",
|
||||||
"graphiql": "3.8.3",
|
"graphiql": "4.1.2",
|
||||||
"graphql": "16.10.0",
|
"graphql": "16.12.0",
|
||||||
"js-cookie": "3.0.5",
|
"js-cookie": "3.0.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
9
netbox/templates/users/panels/token_example.html
Normal file
9
netbox/templates/users/panels/token_example.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends 'ui/panels/_base.html' %}
|
||||||
|
|
||||||
|
{% block panel_content %}
|
||||||
|
<div id="token-example" class="card-body font-monospace">curl -X GET \<br />
|
||||||
|
-H "Authorization: {{ object.get_auth_header_prefix }}<mark><TOKEN></mark>" \<br />
|
||||||
|
-H "Content-Type: application/json" \<br />
|
||||||
|
-H "Accept: application/json; indent=4" \<br />
|
||||||
|
{{ request.scheme }}://{{ request.get_host }}{% url "api-status" %}</div>
|
||||||
|
{% endblock panel_content %}
|
||||||
@@ -1,69 +1,4 @@
|
|||||||
{% extends 'generic/object.html' %}
|
{% extends 'generic/object.html' %}
|
||||||
{% load helpers %}
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load render_table from django_tables2 %}
|
|
||||||
|
|
||||||
{% block title %}{% trans "Token" %} {{ object }}{% endblock %}
|
{% block title %}{% trans "Token" %} {{ object }}{% endblock %}
|
||||||
|
|
||||||
{% block subtitle %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<h2 class="card-header">{% trans "Token" %}</h2>
|
|
||||||
<table class="table table-hover attr-table">
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Version" %}</th>
|
|
||||||
<td>{{ object.version }}</td>
|
|
||||||
</tr>
|
|
||||||
{% if object.version == 1 %}
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Token" %}</th>
|
|
||||||
<td>{{ object.partial }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Key" %}</th>
|
|
||||||
<td>{{ object }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Pepper ID" %}</th>
|
|
||||||
<td>{{ object.pepper_id }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "User" %}</th>
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'users:user' pk=object.user.pk %}">{{ object.user }}</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Description" %}</th>
|
|
||||||
<td>{{ object.description|placeholder }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Write enabled" %}</th>
|
|
||||||
<td>{% checkmark object.write_enabled %}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Created" %}</th>
|
|
||||||
<td>{{ object.created|isodatetime }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Expires" %}</th>
|
|
||||||
<td>{{ object.expires|isodatetime|placeholder }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Last used" %}</th>
|
|
||||||
<td>{{ object.last_used|isodatetime|placeholder }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Allowed IPs" %}</th>
|
|
||||||
<td>{{ object.allowed_ips|join:", "|placeholder }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ class TokenSerializer(ValidatedModelSerializer):
|
|||||||
model = Token
|
model = Token
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'url', 'display_url', 'display', 'version', 'key', 'user', 'description', 'created', 'expires',
|
'id', 'url', 'display_url', 'display', 'version', 'key', 'user', 'description', 'created', 'expires',
|
||||||
'last_used', 'write_enabled', 'pepper_id', 'allowed_ips', 'token',
|
'last_used', 'enabled', 'write_enabled', 'pepper_id', 'allowed_ips', 'token',
|
||||||
)
|
)
|
||||||
read_only_fields = ('key',)
|
read_only_fields = ('key',)
|
||||||
brief_fields = ('id', 'url', 'display', 'version', 'key', 'write_enabled', 'description')
|
brief_fields = ('id', 'url', 'display', 'version', 'key', 'enabled', 'write_enabled', 'description')
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
fields = super().get_fields()
|
fields = super().get_fields()
|
||||||
@@ -79,7 +79,7 @@ class TokenProvisionSerializer(TokenSerializer):
|
|||||||
model = Token
|
model = Token
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'url', 'display_url', 'display', 'version', 'user', 'key', 'created', 'expires', 'last_used', 'key',
|
'id', 'url', 'display_url', 'display', 'version', 'user', 'key', 'created', 'expires', 'last_used', 'key',
|
||||||
'write_enabled', 'description', 'allowed_ips', 'username', 'password', 'token',
|
'enabled', 'write_enabled', 'description', 'allowed_ips', 'username', 'password', 'token',
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ class TokenFilterSet(BaseFilterSet):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Token
|
model = Token
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'version', 'key', 'pepper_id', 'write_enabled', 'description', 'created', 'expires', 'last_used',
|
'id', 'version', 'key', 'pepper_id', 'enabled', 'write_enabled',
|
||||||
|
'description', 'created', 'expires', 'last_used',
|
||||||
)
|
)
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ class TokenBulkEditForm(BulkEditForm):
|
|||||||
queryset=Token.objects.all(),
|
queryset=Token.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput
|
widget=forms.MultipleHiddenInput
|
||||||
)
|
)
|
||||||
|
enabled = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect,
|
||||||
|
label=_('Enabled')
|
||||||
|
)
|
||||||
write_enabled = forms.NullBooleanField(
|
write_enabled = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=BulkEditNullBooleanSelect,
|
widget=BulkEditNullBooleanSelect,
|
||||||
@@ -122,7 +127,7 @@ class TokenBulkEditForm(BulkEditForm):
|
|||||||
|
|
||||||
model = Token
|
model = Token
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('write_enabled', 'description', 'expires', 'allowed_ips'),
|
FieldSet('enabled', 'write_enabled', 'description', 'expires', 'allowed_ips'),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'expires', 'description', 'allowed_ips',
|
'expires', 'description', 'allowed_ips',
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class TokenImportForm(CSVModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Token
|
model = Token
|
||||||
fields = ('user', 'version', 'token', 'write_enabled', 'expires', 'description',)
|
fields = ('user', 'version', 'token', 'enabled', 'write_enabled', 'expires', 'description',)
|
||||||
|
|
||||||
|
|
||||||
class OwnerGroupImportForm(CSVModelForm):
|
class OwnerGroupImportForm(CSVModelForm):
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
model = Token
|
model = Token
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id',),
|
FieldSet('q', 'filter_id',),
|
||||||
FieldSet('version', 'user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')),
|
FieldSet('version', 'user_id', 'enabled', 'write_enabled', 'expires', 'last_used', name=_('Token')),
|
||||||
)
|
)
|
||||||
version = forms.ChoiceField(
|
version = forms.ChoiceField(
|
||||||
choices=add_blank_choice(TokenVersionChoices),
|
choices=add_blank_choice(TokenVersionChoices),
|
||||||
@@ -125,6 +125,13 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('User')
|
label=_('User')
|
||||||
)
|
)
|
||||||
|
enabled = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(
|
||||||
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
|
),
|
||||||
|
label=_('Enabled'),
|
||||||
|
)
|
||||||
write_enabled = forms.NullBooleanField(
|
write_enabled = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Select(
|
widget=forms.Select(
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ class UserTokenForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Token
|
model = Token
|
||||||
fields = [
|
fields = [
|
||||||
'version', 'token', 'write_enabled', 'expires', 'description', 'allowed_ips',
|
'version', 'token', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'expires': DateTimePicker(),
|
'expires': DateTimePicker(),
|
||||||
@@ -177,7 +177,7 @@ class TokenForm(UserTokenForm):
|
|||||||
|
|
||||||
class Meta(UserTokenForm.Meta):
|
class Meta(UserTokenForm.Meta):
|
||||||
fields = [
|
fields = [
|
||||||
'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips',
|
'version', 'token', 'user', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
# Add a new field to enable/disable tokens
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='token',
|
||||||
|
name='enabled',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
|
||||||
# Rename the original key field to "plaintext"
|
# Rename the original key field to "plaintext"
|
||||||
migrations.RenameField(
|
migrations.RenameField(
|
||||||
model_name='token',
|
model_name='token',
|
||||||
@@ -35,7 +42,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
# Add version field to distinguish v1 and v2 tokens
|
# Add a version field to distinguish v1 and v2 tokens
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='token',
|
model_name='token',
|
||||||
name='version',
|
name='version',
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ class Token(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
enabled = models.BooleanField(
|
||||||
|
verbose_name=_('enabled'),
|
||||||
|
default=True,
|
||||||
|
help_text=_('Disable to temporarily revoke this token without deleting it.'),
|
||||||
|
)
|
||||||
write_enabled = models.BooleanField(
|
write_enabled = models.BooleanField(
|
||||||
verbose_name=_('write enabled'),
|
verbose_name=_('write enabled'),
|
||||||
default=True,
|
default=True,
|
||||||
@@ -180,6 +185,31 @@ class Token(models.Model):
|
|||||||
self.key = self.key or self.generate_key()
|
self.key = self.key or self.generate_key()
|
||||||
self.update_digest()
|
self.update_digest()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
"""
|
||||||
|
Check whether the token has expired.
|
||||||
|
"""
|
||||||
|
if self.expires is None or timezone.now() < self.expires:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""
|
||||||
|
Check whether the token is active (enabled and not expired).
|
||||||
|
"""
|
||||||
|
return self.enabled and not self.is_expired
|
||||||
|
|
||||||
|
def get_auth_header_prefix(self):
|
||||||
|
"""
|
||||||
|
Return the HTTP Authorization header prefix for this token.
|
||||||
|
"""
|
||||||
|
if self.v1:
|
||||||
|
return 'Token '
|
||||||
|
if self.v2:
|
||||||
|
return f'Bearer {TOKEN_PREFIX}{self.key}.'
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
@@ -236,12 +266,6 @@ class Token(models.Model):
|
|||||||
hashlib.sha256
|
hashlib.sha256
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
@property
|
|
||||||
def is_expired(self):
|
|
||||||
if self.expires is None or timezone.now() < self.expires:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def validate(self, token):
|
def validate(self, token):
|
||||||
"""
|
"""
|
||||||
Validate the given plaintext against the token.
|
Validate the given plaintext against the token.
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ class TokenTable(NetBoxTable):
|
|||||||
verbose_name=_('token'),
|
verbose_name=_('token'),
|
||||||
template_code=TOKEN,
|
template_code=TOKEN,
|
||||||
)
|
)
|
||||||
|
enabled = columns.BooleanColumn(
|
||||||
|
verbose_name=_('Enabled')
|
||||||
|
)
|
||||||
write_enabled = columns.BooleanColumn(
|
write_enabled = columns.BooleanColumn(
|
||||||
verbose_name=_('Write Enabled')
|
verbose_name=_('Write Enabled')
|
||||||
)
|
)
|
||||||
@@ -49,10 +52,10 @@ class TokenTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Token
|
model = Token
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'token', 'version', 'pepper_id', 'user', 'description', 'write_enabled', 'created', 'expires',
|
'pk', 'id', 'token', 'version', 'pepper_id', 'user', 'description', 'enabled', 'write_enabled', 'created',
|
||||||
'last_used', 'allowed_ips',
|
'expires', 'last_used', 'allowed_ips',
|
||||||
)
|
)
|
||||||
default_columns = ('token', 'version', 'user', 'write_enabled', 'description', 'allowed_ips')
|
default_columns = ('token', 'version', 'user', 'enabled', 'write_enabled', 'description', 'allowed_ips')
|
||||||
|
|
||||||
|
|
||||||
class UserTable(NetBoxTable):
|
class UserTable(NetBoxTable):
|
||||||
|
|||||||
@@ -195,10 +195,10 @@ class TokenTest(
|
|||||||
APIViewTestCases.ListObjectsViewTestCase,
|
APIViewTestCases.ListObjectsViewTestCase,
|
||||||
APIViewTestCases.CreateObjectViewTestCase,
|
APIViewTestCases.CreateObjectViewTestCase,
|
||||||
APIViewTestCases.UpdateObjectViewTestCase,
|
APIViewTestCases.UpdateObjectViewTestCase,
|
||||||
APIViewTestCases.DeleteObjectViewTestCase
|
APIViewTestCases.DeleteObjectViewTestCase,
|
||||||
):
|
):
|
||||||
model = Token
|
model = Token
|
||||||
brief_fields = ['description', 'display', 'id', 'key', 'url', 'version', 'write_enabled']
|
brief_fields = ['description', 'display', 'enabled', 'id', 'key', 'url', 'version', 'write_enabled']
|
||||||
bulk_update_data = {
|
bulk_update_data = {
|
||||||
'description': 'New description',
|
'description': 'New description',
|
||||||
}
|
}
|
||||||
@@ -229,12 +229,16 @@ class TokenTest(
|
|||||||
cls.create_data = [
|
cls.create_data = [
|
||||||
{
|
{
|
||||||
'user': users[0].pk,
|
'user': users[0].pk,
|
||||||
|
'enabled': True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'user': users[1].pk,
|
'user': users[1].pk,
|
||||||
|
'enabled': False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'user': users[2].pk,
|
'user': users[2].pk,
|
||||||
|
'enabled': True,
|
||||||
|
'write_enabled': False,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -267,6 +271,8 @@ class TokenTest(
|
|||||||
self.assertEqual(response.data['expires'], data['expires'])
|
self.assertEqual(response.data['expires'], data['expires'])
|
||||||
token = Token.objects.get(user=user)
|
token = Token.objects.get(user=user)
|
||||||
self.assertEqual(token.key, response.data['key'])
|
self.assertEqual(token.key, response.data['key'])
|
||||||
|
self.assertEqual(token.enabled, response.data['enabled'])
|
||||||
|
self.assertEqual(token.write_enabled, response.data['write_enabled'])
|
||||||
|
|
||||||
def test_provision_token_invalid(self):
|
def test_provision_token_invalid(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
|
|||||||
version=1,
|
version=1,
|
||||||
user=users[0],
|
user=users[0],
|
||||||
expires=future_date,
|
expires=future_date,
|
||||||
|
enabled=True,
|
||||||
write_enabled=True,
|
write_enabled=True,
|
||||||
description='foobar1',
|
description='foobar1',
|
||||||
),
|
),
|
||||||
@@ -292,12 +293,14 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
|
|||||||
version=2,
|
version=2,
|
||||||
user=users[1],
|
user=users[1],
|
||||||
expires=future_date,
|
expires=future_date,
|
||||||
|
enabled=False,
|
||||||
write_enabled=True,
|
write_enabled=True,
|
||||||
description='foobar2',
|
description='foobar2',
|
||||||
),
|
),
|
||||||
Token(
|
Token(
|
||||||
version=2,
|
version=2,
|
||||||
user=users[2],
|
user=users[2],
|
||||||
|
enabled=True,
|
||||||
expires=past_date,
|
expires=past_date,
|
||||||
write_enabled=False,
|
write_enabled=False,
|
||||||
),
|
),
|
||||||
@@ -339,6 +342,12 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
|
|||||||
params = {'expires__lte': '2021-01-01T00:00:00'}
|
params = {'expires__lte': '2021-01-01T00:00:00'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
|
def test_enabled(self):
|
||||||
|
params = {'enabled': True}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'enabled': False}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
def test_write_enabled(self):
|
def test_write_enabled(self):
|
||||||
params = {'write_enabled': True}
|
params = {'write_enabled': True}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|||||||
@@ -20,6 +20,32 @@ class TokenTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
cls.user = create_test_user('User 1')
|
cls.user = create_test_user('User 1')
|
||||||
|
|
||||||
|
def test_is_active(self):
|
||||||
|
"""
|
||||||
|
Test the is_active property.
|
||||||
|
"""
|
||||||
|
# Token with enabled status and no expiration date
|
||||||
|
token = Token(user=self.user, enabled=True, expires=None)
|
||||||
|
self.assertTrue(token.is_active)
|
||||||
|
|
||||||
|
# Token with disabled status
|
||||||
|
token.enabled = False
|
||||||
|
self.assertFalse(token.is_active)
|
||||||
|
|
||||||
|
# Token with enabled status and future expiration
|
||||||
|
future_date = timezone.now() + timedelta(days=1)
|
||||||
|
token = Token(user=self.user, enabled=True, expires=future_date)
|
||||||
|
self.assertTrue(token.is_active)
|
||||||
|
|
||||||
|
# Token with past expiration
|
||||||
|
token.expires = timezone.now() - timedelta(days=1)
|
||||||
|
self.assertFalse(token.is_active)
|
||||||
|
|
||||||
|
# Token with disabled status and past expiration
|
||||||
|
past_date = timezone.now() - timedelta(days=1)
|
||||||
|
token = Token(user=self.user, enabled=False, expires=past_date)
|
||||||
|
self.assertFalse(token.is_active)
|
||||||
|
|
||||||
def test_is_expired(self):
|
def test_is_expired(self):
|
||||||
"""
|
"""
|
||||||
Test the is_expired property.
|
Test the is_expired property.
|
||||||
|
|||||||
@@ -236,13 +236,14 @@ class TokenTestCase(
|
|||||||
'token': '4F9DAouzURLbicyoG55htImgqQ0b4UZHP5LUYgl5',
|
'token': '4F9DAouzURLbicyoG55htImgqQ0b4UZHP5LUYgl5',
|
||||||
'user': users[0].pk,
|
'user': users[0].pk,
|
||||||
'description': 'Test token',
|
'description': 'Test token',
|
||||||
|
'enabled': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"token,user,description",
|
"token,user,description,enabled,write_enabled",
|
||||||
f"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token",
|
f"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token,true,true",
|
||||||
f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token",
|
f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token,true,false",
|
||||||
f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{users[1].pk},Test token",
|
f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{users[1].pk},Test token,false,true",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
|||||||
0
netbox/users/ui/__init__.py
Normal file
0
netbox/users/ui/__init__.py
Normal file
25
netbox/users/ui/panels.py
Normal file
25
netbox/users/ui/panels.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from netbox.ui import actions, attrs, panels
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPanel(panels.ObjectAttributesPanel):
|
||||||
|
version = attrs.NumericAttr('version')
|
||||||
|
key = attrs.TextAttr('key')
|
||||||
|
token = attrs.TextAttr('partial')
|
||||||
|
pepper_id = attrs.NumericAttr('pepper_id')
|
||||||
|
user = attrs.RelatedObjectAttr('user', linkify=True)
|
||||||
|
description = attrs.TextAttr('description')
|
||||||
|
enabled = attrs.BooleanAttr('enabled')
|
||||||
|
write_enabled = attrs.BooleanAttr('write_enabled')
|
||||||
|
expires = attrs.TextAttr('expires')
|
||||||
|
last_used = attrs.TextAttr('last_used')
|
||||||
|
allowed_ips = attrs.TextAttr('allowed_ips')
|
||||||
|
|
||||||
|
|
||||||
|
class TokenExamplePanel(panels.Panel):
|
||||||
|
template_name = 'users/panels/token_example.html'
|
||||||
|
title = _('Example Usage')
|
||||||
|
actions = [
|
||||||
|
actions.CopyContent('token-example')
|
||||||
|
]
|
||||||
@@ -3,7 +3,9 @@ from django.db.models import Count
|
|||||||
from core.models import ObjectChange
|
from core.models import ObjectChange
|
||||||
from core.tables import ObjectChangeTable
|
from core.tables import ObjectChangeTable
|
||||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
|
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
|
||||||
|
from netbox.ui import layout
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
|
from users.ui import panels
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.views import GetRelatedModelsMixin, register_model_view
|
from utilities.views import GetRelatedModelsMixin, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
@@ -26,6 +28,14 @@ class TokenListView(generic.ObjectListView):
|
|||||||
@register_model_view(Token)
|
@register_model_view(Token)
|
||||||
class TokenView(generic.ObjectView):
|
class TokenView(generic.ObjectView):
|
||||||
queryset = Token.objects.all()
|
queryset = Token.objects.all()
|
||||||
|
layout = layout.SimpleLayout(
|
||||||
|
left_panels=[
|
||||||
|
panels.TokenPanel(),
|
||||||
|
],
|
||||||
|
right_panels=[
|
||||||
|
panels.TokenExamplePanel(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Token, 'add', detail=False)
|
@register_model_view(Token, 'add', detail=False)
|
||||||
|
|||||||
Reference in New Issue
Block a user