+
Created
{{ token.created|annotated_date }}
-
+
Expires
{% if token.expires %}
{{ token.expires|annotated_date }}
@@ -34,7 +34,7 @@
Never
{% endif %}
-
+
Create/Edit/Delete Operations
{% if token.write_enabled %}
Enabled
@@ -42,7 +42,14 @@
Disabled
{% endif %}
-
+
+ Allowed Source IPs
+ {% if token.allowed_ips %}
+ {{ token.allowed_ips|join:', ' }}
+ {% else %}
+ Any
+ {% endif %}
+
{% if token.description %}
{{ token.description }}
{% endif %}
diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py
index 1b163ed06..ede26cd1b 100644
--- a/netbox/users/admin/__init__.py
+++ b/netbox/users/admin/__init__.py
@@ -58,9 +58,13 @@ class UserAdmin(UserAdmin_):
class TokenAdmin(admin.ModelAdmin):
form = forms.TokenAdminForm
list_display = [
- 'key', 'user', 'created', 'expires', 'write_enabled', 'description'
+ 'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'list_allowed_ips'
]
+ def list_allowed_ips(self, obj):
+ return obj.allowed_ips or 'Any'
+ list_allowed_ips.short_description = "Allowed IPs"
+
#
# Permissions
diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py
index 7d0212441..bc3d44862 100644
--- a/netbox/users/admin/forms.py
+++ b/netbox/users/admin/forms.py
@@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm):
class Meta:
fields = [
- 'user', 'key', 'write_enabled', 'expires', 'description'
+ 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
]
model = Token
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index d490e8fe9..4b1f5bff3 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -62,7 +62,7 @@ class TokenSerializer(ValidatedModelSerializer):
class Meta:
model = Token
- fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description')
+ fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', 'allowed_ips')
def to_internal_value(self, data):
if 'key' not in data:
diff --git a/netbox/users/forms.py b/netbox/users/forms.py
index d5e6218e5..9720f92b7 100644
--- a/netbox/users/forms.py
+++ b/netbox/users/forms.py
@@ -1,7 +1,9 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
+from django.contrib.postgres.forms import SimpleArrayField
from django.utils.html import mark_safe
+from ipam.formfields import IPNetworkFormField
from netbox.preferences import PREFERENCES
from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect
from utilities.utils import flatten_dict
@@ -100,10 +102,16 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
help_text="If no key is provided, one will be generated automatically."
)
+ allowed_ips = SimpleArrayField(
+ base_field=IPNetworkFormField(),
+ required=False,
+ help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"',
+ )
+
class Meta:
model = Token
fields = [
- 'key', 'write_enabled', 'expires', 'description',
+ 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
]
widgets = {
'expires': DateTimePicker(),
diff --git a/netbox/users/migrations/0003_token_allowed_ips.py b/netbox/users/migrations/0003_token_allowed_ips.py
new file mode 100644
index 000000000..f4eaa9f96
--- /dev/null
+++ b/netbox/users/migrations/0003_token_allowed_ips.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.12 on 2022-04-19 12:37
+
+import django.contrib.postgres.fields
+from django.db import migrations
+import ipam.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0002_standardize_id_fields'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='token',
+ name='allowed_ips',
+ field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None),
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 722ec5ba6..40ff78b98 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -4,17 +4,20 @@ import os
from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
+from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
+from ipam.fields import IPNetworkField
from netbox.config import get_config
from utilities.querysets import RestrictedQuerySet
from utilities.utils import flatten_dict
from .constants import *
+import ipaddress
__all__ = (
'ObjectPermission',
@@ -216,6 +219,12 @@ class Token(models.Model):
max_length=200,
blank=True
)
+ allowed_ips = ArrayField(
+ base_field=IPNetworkField(),
+ blank=True,
+ null=True,
+ help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"',
+ )
class Meta:
pass
@@ -240,6 +249,24 @@ class Token(models.Model):
return False
return True
+ def validate_client_ip(self, raw_ip_address):
+ """
+ Checks that an IP address falls within the allowed IPs.
+ """
+ if not self.allowed_ips:
+ return True
+
+ try:
+ ip_address = ipaddress.ip_address(raw_ip_address)
+ except ValueError as e:
+ raise ValidationError(str(e))
+
+ for ip_network in self.allowed_ips:
+ if ip_address in ipaddress.ip_network(ip_network):
+ return True
+
+ return False
+
#
# Permissions