Skip to content

Commit

Permalink
Merge pull request #9590 from netbox-community/8233-api-token-ip
Browse files Browse the repository at this point in the history
Closes #8233: Restrict API tokens by source IP
  • Loading branch information
jeremystretch authored Jun 23, 2022
2 parents 379880c + 7e4b345 commit f563ba7
Show file tree
Hide file tree
Showing 14 changed files with 221 additions and 16 deletions.
2 changes: 1 addition & 1 deletion docs/models/users/token.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When

By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.

Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. Tokens can also be restricted by IP range: If defined, authentication for API clients connecting from an IP address outside these ranges will fail.
2 changes: 2 additions & 0 deletions docs/release-notes/version-3.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))

#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))

### Enhancements

* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
Expand Down
3 changes: 2 additions & 1 deletion netbox/netbox/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from .fields import *
from .routers import NetBoxRouter
from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer

Expand All @@ -7,6 +7,7 @@
'BulkOperationSerializer',
'ChoiceField',
'ContentTypeField',
'IPNetworkSerializer',
'NetBoxRouter',
'SerializedPKRelatedField',
'ValidatedModelSerializer',
Expand Down
24 changes: 23 additions & 1 deletion netbox/netbox/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,36 @@
from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS

from users.models import Token
from utilities.request import get_client_ip


class TokenAuthentication(authentication.TokenAuthentication):
"""
A custom authentication scheme which enforces Token expiration times.
A custom authentication scheme which enforces Token expiration times and source IP restrictions.
"""
model = Token

def authenticate(self, request):
result = super().authenticate(request)

if result:
token = result[1]

# Enforce source IP restrictions (if any) set on the token
if token.allowed_ips:
client_ip = get_client_ip(request)
if client_ip is None:
raise exceptions.AuthenticationFailed(
"Client IP address could not be determined for validation. Check that the HTTP server is "
"correctly configured to pass the required header(s)."
)
if not token.validate_client_ip(client_ip):
raise exceptions.AuthenticationFailed(
f"Source IP {client_ip} is not permitted to authenticate using this token."
)

return result

def authenticate_credentials(self, key):
model = self.get_model()
try:
Expand Down
21 changes: 19 additions & 2 deletions netbox/netbox/api/fields.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
from collections import OrderedDict

import pytz
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from netaddr import IPNetwork
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField

__all__ = (
'ChoiceField',
'ContentTypeField',
'IPNetworkSerializer',
'SerializedPKRelatedField',
)


class ChoiceField(serializers.Field):
"""
Expand Down Expand Up @@ -104,6 +110,17 @@ def to_representation(self, obj):
return f"{obj.app_label}.{obj.model}"


class IPNetworkSerializer(serializers.Serializer):
"""
Representation of an IP network value (e.g. 192.0.2.0/24).
"""
def to_representation(self, instance):
return str(instance)

def to_internal_value(self, value):
return IPNetwork(value)


class SerializedPKRelatedField(PrimaryKeyRelatedField):
"""
Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
Expand Down
67 changes: 66 additions & 1 deletion netbox/netbox/tests/test_authentication.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import datetime

from django.conf import settings
from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType
Expand All @@ -8,10 +10,73 @@
from rest_framework.test import APIClient

from dcim.models import Site
from ipam.choices import PrefixStatusChoices
from ipam.models import Prefix
from users.models import ObjectPermission, Token
from utilities.testing import TestCase
from utilities.testing.api import APITestCase


class TokenAuthenticationTestCase(APITestCase):

@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
def test_token_authentication(self):
url = reverse('dcim-api:site-list')

# Request without a token should return a 403
response = self.client.get(url)
self.assertEqual(response.status_code, 403)

# Valid token should return a 200
token = Token.objects.create(user=self.user)
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}')
self.assertEqual(response.status_code, 200)

@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
def test_token_expiration(self):
url = reverse('dcim-api:site-list')

# Request without a non-expired token should succeed
token = Token.objects.create(user=self.user)
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}')
self.assertEqual(response.status_code, 200)

# Request with an expired token should fail
token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc)
token.save()
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}')
self.assertEqual(response.status_code, 403)

@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
def test_token_write_enabled(self):
url = reverse('dcim-api:site-list')
data = {
'name': 'Site 1',
'slug': 'site-1',
}

# Request with a write-disabled token should fail
token = Token.objects.create(user=self.user, write_enabled=False)
response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}')
self.assertEqual(response.status_code, 403)

# Request with a write-enabled token should succeed
token.write_enabled = True
token.save()
response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}')
self.assertEqual(response.status_code, 403)

@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
def test_token_allowed_ips(self):
url = reverse('dcim-api:site-list')

# Request from a non-allowed client IP should fail
token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24'])
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1')
self.assertEqual(response.status_code, 403)

# Request with an expired token should fail
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1')
self.assertEqual(response.status_code, 200)


class ExternalAuthenticationTestCase(TestCase):
Expand Down
15 changes: 11 additions & 4 deletions netbox/templates/users/api_tokens.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,34 @@
</div>
<div class="card-body">
<div class="row">
<div class="col col-md-4">
<div class="col col-md-3">
<small class="text-muted">Created</small><br />
{{ token.created|annotated_date }}
</div>
<div class="col col-md-4">
<div class="col col-md-3">
<small class="text-muted">Expires</small><br />
{% if token.expires %}
{{ token.expires|annotated_date }}
{% else %}
<span>Never</span>
{% endif %}
</div>
<div class="col col-md-4">
<div class="col col-md-3">
<small class="text-muted">Create/Edit/Delete Operations</small><br />
{% if token.write_enabled %}
<span class="badge bg-success">Enabled</span>
{% else %}
<span class="badge bg-danger">Disabled</span>
{% endif %}
</div>
</div>
<div class="col col-md-3">
<small class="text-muted">Allowed Source IPs</small><br />
{% if token.allowed_ips %}
{{ token.allowed_ips|join:', ' }}
{% else %}
<span>Any</span>
{% endif %}
</div> </div>
{% if token.description %}
<br /><span>{{ token.description }}</span>
{% endif %}
Expand Down
6 changes: 5 additions & 1 deletion netbox/users/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,13 @@ def get_inlines(self, request, obj):
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
Expand Down
2 changes: 1 addition & 1 deletion netbox/users/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 11 additions & 2 deletions netbox/users/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers

from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
from netbox.api import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField, ValidatedModelSerializer
from users.models import ObjectPermission, Token
from .nested_serializers import *

Expand Down Expand Up @@ -64,10 +64,19 @@ class TokenSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')
key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False)
user = NestedUserSerializer()
allowed_ips = serializers.ListField(
child=IPNetworkSerializer(),
required=False,
allow_empty=True,
default=[]
)

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:
Expand Down
11 changes: 10 additions & 1 deletion netbox/users/forms.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -99,11 +101,18 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
required=False,
help_text="If no key is provided, one will be generated automatically."
)
allowed_ips = SimpleArrayField(
base_field=IPNetworkFormField(),
required=False,
label='Allowed IPs',
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(),
Expand Down
20 changes: 20 additions & 0 deletions netbox/users/migrations/0003_token_allowed_ips.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
24 changes: 23 additions & 1 deletion netbox/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from netaddr import IPNetwork

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 *


__all__ = (
'ObjectPermission',
'Token',
Expand Down Expand Up @@ -216,6 +217,14 @@ class Token(models.Model):
max_length=200,
blank=True
)
allowed_ips = ArrayField(
base_field=IPNetworkField(),
blank=True,
null=True,
verbose_name='Allowed IPs',
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
Expand All @@ -240,6 +249,19 @@ def is_expired(self):
return False
return True

def validate_client_ip(self, client_ip):
"""
Validate the API client IP address against the source IP restrictions (if any) set on the token.
"""
if not self.allowed_ips:
return True

for ip_network in self.allowed_ips:
if client_ip in IPNetwork(ip_network):
return True

return False


#
# Permissions
Expand Down
Loading

0 comments on commit f563ba7

Please sign in to comment.