Merge branch 'develop' of https://github.com/PieterL75/netbox into issue_7839

This commit is contained in:
Pieter Lambrecht 2022-03-18 09:17:23 +01:00
commit 7e624a0726
12 changed files with 107 additions and 8 deletions

View File

@ -113,3 +113,7 @@ svgwrite
# Tabular dataset library (for table-based exports) # Tabular dataset library (for table-based exports)
# https://github.com/jazzband/tablib # https://github.com/jazzband/tablib
tablib tablib
# It changes comma separated widget to list based in admin panel
# https://github.com/gradam/django-better-admin-arrayfield
django_better_admin_arrayfield

View File

@ -2,6 +2,10 @@
## v3.1.10 (FUTURE) ## v3.1.10 (FUTURE)
### Enhancements
* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key usage by source IP
### Bug Fixes ### Bug Fixes
* [#8820](https://github.com/netbox-community/netbox/issues/8820) - Fix navbar background color in dark mode * [#8820](https://github.com/netbox-community/netbox/issues/8820) - Fix navbar background color in dark mode

View File

@ -10,6 +10,11 @@ class TokenAuthentication(authentication.TokenAuthentication):
A custom authentication scheme which enforces Token expiration times. A custom authentication scheme which enforces Token expiration times.
""" """
model = Token model = Token
__request = False
def authenticate(self, request):
self.request = request
return super().authenticate(request)
def authenticate_credentials(self, key): def authenticate_credentials(self, key):
model = self.get_model() model = self.get_model()
@ -18,6 +23,20 @@ class TokenAuthentication(authentication.TokenAuthentication):
except model.DoesNotExist: except model.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid token") raise exceptions.AuthenticationFailed("Invalid token")
# Verify source IP is allowed
request = self.request
if token.allowed_ips and request:
# Replace 'HTTP_X_REAL_IP' with the settings variable choosen in #8867
if 'HTTP_X_REAL_IP' in request.META:
clientip = request.META['HTTP_X_REAL_IP'].split(",")[0].strip()
elif 'REMOTE_ADDR' in request.META:
clientip = request.META['REMOTE_ADDR']
else:
raise exceptions.AuthenticationFailed(f"The request HTTP headers (HTTP_X_REAL_IP, REMOTE_ADDR) are missing or do not contain a valid source IP.")
if not token.validate_client_ip(clientip):
raise exceptions.AuthenticationFailed(f"Source IP {clientip} is not allowed to use this token.")
# 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")

View File

@ -321,6 +321,7 @@ INSTALLED_APPS = [
'wireless', 'wireless',
'django_rq', # Must come after extras to allow overriding management commands 'django_rq', # Must come after extras to allow overriding management commands
'drf_yasg', 'drf_yasg',
'django_better_admin_arrayfield',
] ]
# Middleware # Middleware

View File

@ -22,11 +22,11 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col col-md-4"> <div class="col col-md-3">
<small class="text-muted">Created</small><br /> <small class="text-muted">Created</small><br />
{{ token.created|annotated_date }} {{ token.created|annotated_date }}
</div> </div>
<div class="col col-md-4"> <div class="col col-md-3">
<small class="text-muted">Expires</small><br /> <small class="text-muted">Expires</small><br />
{% if token.expires %} {% if token.expires %}
{{ token.expires|annotated_date }} {{ token.expires|annotated_date }}
@ -34,7 +34,7 @@
<span>Never</span> <span>Never</span>
{% endif %} {% endif %}
</div> </div>
<div class="col col-md-4"> <div class="col col-md-3">
<small class="text-muted">Create/Edit/Delete Operations</small><br /> <small class="text-muted">Create/Edit/Delete Operations</small><br />
{% if token.write_enabled %} {% if token.write_enabled %}
<span class="badge bg-success">Enabled</span> <span class="badge bg-success">Enabled</span>
@ -42,6 +42,14 @@
<span class="badge bg-danger">Disabled</span> <span class="badge bg-danger">Disabled</span>
{% endif %} {% 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> </div>
{% if token.description %} {% if token.description %}
<br /><span>{{ token.description }}</span> <br /><span>{{ token.description }}</span>

View File

@ -1,6 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin
from users.models import ObjectPermission, Token from users.models import ObjectPermission, Token
from . import filters, forms, inlines from . import filters, forms, inlines
@ -55,12 +56,17 @@ class UserAdmin(UserAdmin_):
# #
@admin.register(Token) @admin.register(Token)
class TokenAdmin(admin.ModelAdmin): class TokenAdmin(admin.ModelAdmin, DynamicArrayMixin):
form = forms.TokenAdminForm form = forms.TokenAdminForm
list_display = [ 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
list_allowed_ips.empty_value_display = 'Any'
list_allowed_ips.short_description = "Allowed IPs"
# #
# Permissions # Permissions

View File

@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm):
class Meta: class Meta:
fields = [ fields = [
'user', 'key', 'write_enabled', 'expires', 'description' 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
] ]
model = Token model = Token

View File

@ -62,7 +62,7 @@ class TokenSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = Token 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): def to_internal_value(self, data):
if 'key' not in data: if 'key' not in data:

View File

@ -1,8 +1,10 @@
from django import forms from django import forms
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
from django.contrib.postgres.forms import SimpleArrayField
from utilities.forms import BootstrapMixin, DateTimePicker from utilities.forms import BootstrapMixin, DateTimePicker
from .models import Token from .models import Token
from ipam.formfields import IPNetworkFormField
class LoginForm(BootstrapMixin, AuthenticationForm): class LoginForm(BootstrapMixin, AuthenticationForm):
@ -18,11 +20,16 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
required=False, required=False,
help_text="If no key is provided, one will be generated automatically." 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: class Meta:
model = Token model = Token
fields = [ fields = [
'key', 'write_enabled', 'expires', 'description', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
] ]
widgets = { widgets = {
'expires': DateTimePicker(), 'expires': DateTimePicker(),

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.12 on 2022-03-15 13:08
from django.db import migrations
import django_better_admin_arrayfield.models.fields
import ipam.fields
class Migration(migrations.Migration):
dependencies = [
('users', '0001_squashed_0011'),
]
operations = [
migrations.AddField(
model_name='token',
name='allowed_ips',
field=django_better_admin_arrayfield.models.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None),
),
]

View File

@ -4,17 +4,22 @@ import os
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from django_better_admin_arrayfield.models.fields import ArrayField as betterArrayField
from netbox.models import BigIDModel from netbox.models import BigIDModel
from ipam.fields import IPNetworkField
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import flatten_dict from utilities.utils import flatten_dict
from .constants import * from .constants import *
import ipaddress
__all__ = ( __all__ = (
'ObjectPermission', 'ObjectPermission',
@ -203,6 +208,12 @@ class Token(BigIDModel):
max_length=200, max_length=200,
blank=True blank=True
) )
allowed_ips = betterArrayField(
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: class Meta:
pass pass
@ -227,6 +238,24 @@ class Token(BigIDModel):
return False return False
return True 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:
raise ValidationError(f"{raw_ip_address} is an invalid IP address")
for ip_network in self.allowed_ips:
if ip_address in ipaddress.ip_network(ip_network):
return True
return False
# #
# Permissions # Permissions

View File

@ -28,6 +28,7 @@ social-auth-core==4.2.0
svgwrite==1.4.1 svgwrite==1.4.1
tablib==3.2.0 tablib==3.2.0
tzdata==2021.5 tzdata==2021.5
django_better_admin_arrayfield==1.4.2
# Workaround for #7401 # Workaround for #7401
jsonschema==3.2.0 jsonschema==3.2.0