Skip to content

Commit f563ba7

Browse files
Merge pull request #9590 from netbox-community/8233-api-token-ip
Closes #8233: Restrict API tokens by source IP
2 parents 379880c + 7e4b345 commit f563ba7

File tree

14 files changed

+221
-16
lines changed

14 files changed

+221
-16
lines changed

docs/models/users/token.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When
99

1010
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.
1111

12-
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.
12+
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.

docs/release-notes/version-3.3.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

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

16+
#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
17+
1618
### Enhancements
1719

1820
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses

netbox/netbox/api/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
1+
from .fields import *
22
from .routers import NetBoxRouter
33
from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer
44

@@ -7,6 +7,7 @@
77
'BulkOperationSerializer',
88
'ChoiceField',
99
'ContentTypeField',
10+
'IPNetworkSerializer',
1011
'NetBoxRouter',
1112
'SerializedPKRelatedField',
1213
'ValidatedModelSerializer',

netbox/netbox/api/authentication.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,36 @@
33
from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS
44

55
from users.models import Token
6+
from utilities.request import get_client_ip
67

78

89
class TokenAuthentication(authentication.TokenAuthentication):
910
"""
10-
A custom authentication scheme which enforces Token expiration times.
11+
A custom authentication scheme which enforces Token expiration times and source IP restrictions.
1112
"""
1213
model = Token
1314

15+
def authenticate(self, request):
16+
result = super().authenticate(request)
17+
18+
if result:
19+
token = result[1]
20+
21+
# Enforce source IP restrictions (if any) set on the token
22+
if token.allowed_ips:
23+
client_ip = get_client_ip(request)
24+
if client_ip is None:
25+
raise exceptions.AuthenticationFailed(
26+
"Client IP address could not be determined for validation. Check that the HTTP server is "
27+
"correctly configured to pass the required header(s)."
28+
)
29+
if not token.validate_client_ip(client_ip):
30+
raise exceptions.AuthenticationFailed(
31+
f"Source IP {client_ip} is not permitted to authenticate using this token."
32+
)
33+
34+
return result
35+
1436
def authenticate_credentials(self, key):
1537
model = self.get_model()
1638
try:

netbox/netbox/api/fields.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
from collections import OrderedDict
22

3-
import pytz
4-
from django.contrib.contenttypes.models import ContentType
53
from django.core.exceptions import ObjectDoesNotExist
4+
from netaddr import IPNetwork
65
from rest_framework import serializers
76
from rest_framework.exceptions import ValidationError
87
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
98

9+
__all__ = (
10+
'ChoiceField',
11+
'ContentTypeField',
12+
'IPNetworkSerializer',
13+
'SerializedPKRelatedField',
14+
)
15+
1016

1117
class ChoiceField(serializers.Field):
1218
"""
@@ -104,6 +110,17 @@ def to_representation(self, obj):
104110
return f"{obj.app_label}.{obj.model}"
105111

106112

113+
class IPNetworkSerializer(serializers.Serializer):
114+
"""
115+
Representation of an IP network value (e.g. 192.0.2.0/24).
116+
"""
117+
def to_representation(self, instance):
118+
return str(instance)
119+
120+
def to_internal_value(self, value):
121+
return IPNetwork(value)
122+
123+
107124
class SerializedPKRelatedField(PrimaryKeyRelatedField):
108125
"""
109126
Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related

netbox/netbox/tests/test_authentication.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import datetime
2+
13
from django.conf import settings
24
from django.contrib.auth.models import Group, User
35
from django.contrib.contenttypes.models import ContentType
@@ -8,10 +10,73 @@
810
from rest_framework.test import APIClient
911

1012
from dcim.models import Site
11-
from ipam.choices import PrefixStatusChoices
1213
from ipam.models import Prefix
1314
from users.models import ObjectPermission, Token
1415
from utilities.testing import TestCase
16+
from utilities.testing.api import APITestCase
17+
18+
19+
class TokenAuthenticationTestCase(APITestCase):
20+
21+
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
22+
def test_token_authentication(self):
23+
url = reverse('dcim-api:site-list')
24+
25+
# Request without a token should return a 403
26+
response = self.client.get(url)
27+
self.assertEqual(response.status_code, 403)
28+
29+
# Valid token should return a 200
30+
token = Token.objects.create(user=self.user)
31+
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}')
32+
self.assertEqual(response.status_code, 200)
33+
34+
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
35+
def test_token_expiration(self):
36+
url = reverse('dcim-api:site-list')
37+
38+
# Request without a non-expired token should succeed
39+
token = Token.objects.create(user=self.user)
40+
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}')
41+
self.assertEqual(response.status_code, 200)
42+
43+
# Request with an expired token should fail
44+
token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc)
45+
token.save()
46+
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}')
47+
self.assertEqual(response.status_code, 403)
48+
49+
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
50+
def test_token_write_enabled(self):
51+
url = reverse('dcim-api:site-list')
52+
data = {
53+
'name': 'Site 1',
54+
'slug': 'site-1',
55+
}
56+
57+
# Request with a write-disabled token should fail
58+
token = Token.objects.create(user=self.user, write_enabled=False)
59+
response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}')
60+
self.assertEqual(response.status_code, 403)
61+
62+
# Request with a write-enabled token should succeed
63+
token.write_enabled = True
64+
token.save()
65+
response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}')
66+
self.assertEqual(response.status_code, 403)
67+
68+
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
69+
def test_token_allowed_ips(self):
70+
url = reverse('dcim-api:site-list')
71+
72+
# Request from a non-allowed client IP should fail
73+
token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24'])
74+
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1')
75+
self.assertEqual(response.status_code, 403)
76+
77+
# Request with an expired token should fail
78+
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1')
79+
self.assertEqual(response.status_code, 200)
1580

1681

1782
class ExternalAuthenticationTestCase(TestCase):

netbox/templates/users/api_tokens.html

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,27 +22,34 @@
2222
</div>
2323
<div class="card-body">
2424
<div class="row">
25-
<div class="col col-md-4">
25+
<div class="col col-md-3">
2626
<small class="text-muted">Created</small><br />
2727
{{ token.created|annotated_date }}
2828
</div>
29-
<div class="col col-md-4">
29+
<div class="col col-md-3">
3030
<small class="text-muted">Expires</small><br />
3131
{% if token.expires %}
3232
{{ token.expires|annotated_date }}
3333
{% else %}
3434
<span>Never</span>
3535
{% endif %}
3636
</div>
37-
<div class="col col-md-4">
37+
<div class="col col-md-3">
3838
<small class="text-muted">Create/Edit/Delete Operations</small><br />
3939
{% if token.write_enabled %}
4040
<span class="badge bg-success">Enabled</span>
4141
{% else %}
4242
<span class="badge bg-danger">Disabled</span>
4343
{% endif %}
4444
</div>
45-
</div>
45+
<div class="col col-md-3">
46+
<small class="text-muted">Allowed Source IPs</small><br />
47+
{% if token.allowed_ips %}
48+
{{ token.allowed_ips|join:', ' }}
49+
{% else %}
50+
<span>Any</span>
51+
{% endif %}
52+
</div> </div>
4653
{% if token.description %}
4754
<br /><span>{{ token.description }}</span>
4855
{% endif %}

netbox/users/admin/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,13 @@ def get_inlines(self, request, obj):
5858
class TokenAdmin(admin.ModelAdmin):
5959
form = forms.TokenAdminForm
6060
list_display = [
61-
'key', 'user', 'created', 'expires', 'write_enabled', 'description'
61+
'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'list_allowed_ips'
6262
]
6363

64+
def list_allowed_ips(self, obj):
65+
return obj.allowed_ips or 'Any'
66+
list_allowed_ips.short_description = "Allowed IPs"
67+
6468

6569
#
6670
# Permissions

netbox/users/admin/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm):
5151

5252
class Meta:
5353
fields = [
54-
'user', 'key', 'write_enabled', 'expires', 'description'
54+
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
5555
]
5656
model = Token
5757

netbox/users/api/serializers.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.contrib.contenttypes.models import ContentType
33
from rest_framework import serializers
44

5-
from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
5+
from netbox.api import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField, ValidatedModelSerializer
66
from users.models import ObjectPermission, Token
77
from .nested_serializers import *
88

@@ -64,10 +64,19 @@ class TokenSerializer(ValidatedModelSerializer):
6464
url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')
6565
key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False)
6666
user = NestedUserSerializer()
67+
allowed_ips = serializers.ListField(
68+
child=IPNetworkSerializer(),
69+
required=False,
70+
allow_empty=True,
71+
default=[]
72+
)
6773

6874
class Meta:
6975
model = Token
70-
fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description')
76+
fields = (
77+
'id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description',
78+
'allowed_ips',
79+
)
7180

7281
def to_internal_value(self, data):
7382
if 'key' not in data:

0 commit comments

Comments
 (0)