mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 17:38:37 -06:00
* Introduce 'accounts' app for user-specific views & resources * Move UserTokenTable to account app * Move login & logout views to account app
This commit is contained in:
parent
9c6c3d3dd4
commit
80376abedf
0
netbox/account/__init__.py
Normal file
0
netbox/account/__init__.py
Normal file
@ -1,10 +1,12 @@
|
|||||||
# Generated by Django 4.1.10 on 2023-07-25 15:19
|
# Generated by Django 4.1.10 on 2023-07-30 17:49
|
||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('users', '0004_netboxgroup_netboxuser'),
|
('users', '0004_netboxgroup_netboxuser'),
|
||||||
]
|
]
|
||||||
@ -15,10 +17,10 @@ class Migration(migrations.Migration):
|
|||||||
fields=[
|
fields=[
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
'verbose_name': 'token',
|
||||||
'proxy': True,
|
'proxy': True,
|
||||||
'indexes': [],
|
'indexes': [],
|
||||||
'constraints': [],
|
'constraints': [],
|
||||||
'verbose_name': 'token',
|
|
||||||
},
|
},
|
||||||
bases=('users.token',),
|
bases=('users.token',),
|
||||||
),
|
),
|
0
netbox/account/migrations/__init__.py
Normal file
0
netbox/account/migrations/__init__.py
Normal file
15
netbox/account/models.py
Normal file
15
netbox/account/models.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from users.models import Token
|
||||||
|
|
||||||
|
|
||||||
|
class UserToken(Token):
|
||||||
|
"""
|
||||||
|
Proxy model for users to manage their own API tokens.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
proxy = True
|
||||||
|
verbose_name = 'token'
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('account:usertoken', args=[self.pk])
|
55
netbox/account/tables.py
Normal file
55
netbox/account/tables.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from account.models import UserToken
|
||||||
|
from netbox.tables import NetBoxTable, columns
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'UserTokenTable',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = """<samp><span id="token_{{ record.pk }}">{{ record }}</span></samp>"""
|
||||||
|
|
||||||
|
ALLOWED_IPS = """{{ value|join:", " }}"""
|
||||||
|
|
||||||
|
COPY_BUTTON = """
|
||||||
|
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
|
||||||
|
{% copy_content record.pk prefix="token_" color="success" %}
|
||||||
|
{% endif %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenTable(NetBoxTable):
|
||||||
|
"""
|
||||||
|
Table for users to manager their own API tokens under account views.
|
||||||
|
"""
|
||||||
|
key = columns.TemplateColumn(
|
||||||
|
verbose_name=_('Key'),
|
||||||
|
template_code=TOKEN,
|
||||||
|
)
|
||||||
|
write_enabled = columns.BooleanColumn(
|
||||||
|
verbose_name=_('Write Enabled')
|
||||||
|
)
|
||||||
|
created = columns.DateColumn(
|
||||||
|
verbose_name=_('Created'),
|
||||||
|
)
|
||||||
|
expires = columns.DateColumn(
|
||||||
|
verbose_name=_('Expires'),
|
||||||
|
)
|
||||||
|
last_used = columns.DateTimeColumn(
|
||||||
|
verbose_name=_('Last Used'),
|
||||||
|
)
|
||||||
|
allowed_ips = columns.TemplateColumn(
|
||||||
|
verbose_name=_('Allowed IPs'),
|
||||||
|
template_code=ALLOWED_IPS
|
||||||
|
)
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
actions=('edit', 'delete'),
|
||||||
|
extra_buttons=COPY_BUTTON
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = UserToken
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
|
||||||
|
)
|
@ -1,5 +1,6 @@
|
|||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from utilities.urls import get_model_urls
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
app_name = 'account'
|
app_name = 'account'
|
||||||
@ -12,8 +13,6 @@ urlpatterns = [
|
|||||||
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
|
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
|
||||||
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
|
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
|
||||||
path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
|
path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
|
||||||
path('api-tokens/<int:pk>/', views.UserTokenView.as_view(), name='usertoken'),
|
path('api-tokens/<int:pk>/', include(get_model_urls('account', 'usertoken'))),
|
||||||
path('api-tokens/<int:pk>/edit/', views.UserTokenEditView.as_view(), name='usertoken_edit'),
|
|
||||||
path('api-tokens/<int:pk>/delete/', views.UserTokenDeleteView.as_view(), name='usertoken_delete'),
|
|
||||||
|
|
||||||
]
|
]
|
298
netbox/account/views.py
Normal file
298
netbox/account/views.py
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import login as auth_login, logout as auth_logout
|
||||||
|
from django.contrib.auth import update_session_auth_hash
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib.auth.models import update_last_login
|
||||||
|
from django.contrib.auth.signals import user_logged_in
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.shortcuts import render, resolve_url
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
|
||||||
|
from django.views.decorators.debug import sensitive_post_parameters
|
||||||
|
from django.views.generic import View
|
||||||
|
from social_core.backends.utils import load_backends
|
||||||
|
|
||||||
|
from account.models import UserToken
|
||||||
|
from extras.models import Bookmark, ObjectChange
|
||||||
|
from extras.tables import BookmarkTable, ObjectChangeTable
|
||||||
|
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
||||||
|
from netbox.config import get_config
|
||||||
|
from netbox.views import generic
|
||||||
|
from users import forms, tables
|
||||||
|
from users.models import UserConfig
|
||||||
|
from utilities.views import register_model_view
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Login/logout
|
||||||
|
#
|
||||||
|
|
||||||
|
class LoginView(View):
|
||||||
|
"""
|
||||||
|
Perform user authentication via the web UI.
|
||||||
|
"""
|
||||||
|
template_name = 'login.html'
|
||||||
|
|
||||||
|
@method_decorator(sensitive_post_parameters('password'))
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
def gen_auth_data(self, name, url, params):
|
||||||
|
display_name, icon_name = get_auth_backend_display(name)
|
||||||
|
return {
|
||||||
|
'display_name': display_name,
|
||||||
|
'icon_name': icon_name,
|
||||||
|
'url': f'{url}?{urlencode(params)}',
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_auth_backends(self, request):
|
||||||
|
auth_backends = []
|
||||||
|
saml_idps = get_saml_idps()
|
||||||
|
|
||||||
|
for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
|
||||||
|
url = reverse('social:begin', args=[name])
|
||||||
|
params = {}
|
||||||
|
if next := request.GET.get('next'):
|
||||||
|
params['next'] = next
|
||||||
|
if name.lower() == 'saml' and saml_idps:
|
||||||
|
for idp in saml_idps:
|
||||||
|
params['idp'] = idp
|
||||||
|
data = self.gen_auth_data(name, url, params)
|
||||||
|
data['display_name'] = f'{data["display_name"]} ({idp})'
|
||||||
|
auth_backends.append(data)
|
||||||
|
else:
|
||||||
|
auth_backends.append(self.gen_auth_data(name, url, params))
|
||||||
|
|
||||||
|
return auth_backends
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
form = forms.LoginForm(request)
|
||||||
|
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
logger = logging.getLogger('netbox.auth.login')
|
||||||
|
return self.redirect_to_next(request, logger)
|
||||||
|
|
||||||
|
return render(request, self.template_name, {
|
||||||
|
'form': form,
|
||||||
|
'auth_backends': self.get_auth_backends(request),
|
||||||
|
})
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
logger = logging.getLogger('netbox.auth.login')
|
||||||
|
form = forms.LoginForm(request, data=request.POST)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
logger.debug("Login form validation was successful")
|
||||||
|
|
||||||
|
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
|
||||||
|
# last_login time upon authentication.
|
||||||
|
if get_config().MAINTENANCE_MODE:
|
||||||
|
logger.warning("Maintenance mode enabled: disabling update of most recent login time")
|
||||||
|
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
|
||||||
|
|
||||||
|
# Authenticate user
|
||||||
|
auth_login(request, form.get_user())
|
||||||
|
logger.info(f"User {request.user} successfully authenticated")
|
||||||
|
messages.success(request, f"Logged in as {request.user}.")
|
||||||
|
|
||||||
|
# Ensure the user has a UserConfig defined. (This should normally be handled by
|
||||||
|
# create_userconfig() on user creation.)
|
||||||
|
if not hasattr(request.user, 'config'):
|
||||||
|
config = get_config()
|
||||||
|
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
|
||||||
|
|
||||||
|
return self.redirect_to_next(request, logger)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
|
||||||
|
|
||||||
|
return render(request, self.template_name, {
|
||||||
|
'form': form,
|
||||||
|
'auth_backends': self.get_auth_backends(request),
|
||||||
|
})
|
||||||
|
|
||||||
|
def redirect_to_next(self, request, logger):
|
||||||
|
data = request.POST if request.method == "POST" else request.GET
|
||||||
|
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
|
||||||
|
|
||||||
|
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
|
||||||
|
logger.debug(f"Redirecting user to {redirect_url}")
|
||||||
|
else:
|
||||||
|
if redirect_url:
|
||||||
|
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
|
||||||
|
redirect_url = reverse('home')
|
||||||
|
|
||||||
|
return HttpResponseRedirect(redirect_url)
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutView(View):
|
||||||
|
"""
|
||||||
|
Deauthenticate a web user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
logger = logging.getLogger('netbox.auth.logout')
|
||||||
|
|
||||||
|
# Log out the user
|
||||||
|
username = request.user
|
||||||
|
auth_logout(request)
|
||||||
|
logger.info(f"User {username} has logged out")
|
||||||
|
messages.info(request, "You have logged out.")
|
||||||
|
|
||||||
|
# Delete session key cookie (if set) upon logout
|
||||||
|
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
|
||||||
|
response.delete_cookie('session_key')
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# User profiles
|
||||||
|
#
|
||||||
|
|
||||||
|
class ProfileView(LoginRequiredMixin, View):
|
||||||
|
template_name = 'account/profile.html'
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
|
||||||
|
# Compile changelog table
|
||||||
|
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||||
|
user=request.user
|
||||||
|
).prefetch_related(
|
||||||
|
'changed_object_type'
|
||||||
|
)[:20]
|
||||||
|
changelog_table = ObjectChangeTable(changelog)
|
||||||
|
|
||||||
|
return render(request, self.template_name, {
|
||||||
|
'changelog_table': changelog_table,
|
||||||
|
'active_tab': 'profile',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfigView(LoginRequiredMixin, View):
|
||||||
|
template_name = 'account/preferences.html'
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
userconfig = request.user.config
|
||||||
|
form = forms.UserConfigForm(instance=userconfig)
|
||||||
|
|
||||||
|
return render(request, self.template_name, {
|
||||||
|
'form': form,
|
||||||
|
'active_tab': 'preferences',
|
||||||
|
})
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
userconfig = request.user.config
|
||||||
|
form = forms.UserConfigForm(request.POST, instance=userconfig)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
|
||||||
|
messages.success(request, "Your preferences have been updated.")
|
||||||
|
return redirect('account:preferences')
|
||||||
|
|
||||||
|
return render(request, self.template_name, {
|
||||||
|
'form': form,
|
||||||
|
'active_tab': 'preferences',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordView(LoginRequiredMixin, View):
|
||||||
|
template_name = 'account/password.html'
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# LDAP users cannot change their password here
|
||||||
|
if getattr(request.user, 'ldap_username', None):
|
||||||
|
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
|
||||||
|
return redirect('account:profile')
|
||||||
|
|
||||||
|
form = forms.PasswordChangeForm(user=request.user)
|
||||||
|
|
||||||
|
return render(request, self.template_name, {
|
||||||
|
'form': form,
|
||||||
|
'active_tab': 'password',
|
||||||
|
})
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
form = forms.PasswordChangeForm(user=request.user, data=request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
update_session_auth_hash(request, form.user)
|
||||||
|
messages.success(request, "Your password has been changed successfully.")
|
||||||
|
return redirect('account:profile')
|
||||||
|
|
||||||
|
return render(request, self.template_name, {
|
||||||
|
'form': form,
|
||||||
|
'active_tab': 'change_password',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
|
||||||
|
table = BookmarkTable
|
||||||
|
template_name = 'account/bookmarks.html'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return Bookmark.objects.filter(user=request.user)
|
||||||
|
|
||||||
|
def get_extra_context(self, request):
|
||||||
|
return {
|
||||||
|
'active_tab': 'bookmarks',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# User views for token management
|
||||||
|
#
|
||||||
|
|
||||||
|
class UserTokenListView(LoginRequiredMixin, View):
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
tokens = UserToken.objects.filter(user=request.user)
|
||||||
|
table = tables.UserTokenTable(tokens)
|
||||||
|
table.configure(request)
|
||||||
|
|
||||||
|
return render(request, 'account/token_list.html', {
|
||||||
|
'tokens': tokens,
|
||||||
|
'active_tab': 'api-tokens',
|
||||||
|
'table': table,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(UserToken)
|
||||||
|
class UserTokenView(LoginRequiredMixin, View):
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
|
||||||
|
key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
|
||||||
|
|
||||||
|
return render(request, 'account/token.html', {
|
||||||
|
'object': token,
|
||||||
|
'key': key,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(UserToken, 'edit')
|
||||||
|
class UserTokenEditView(generic.ObjectEditView):
|
||||||
|
queryset = UserToken.objects.all()
|
||||||
|
form = forms.UserTokenForm
|
||||||
|
default_return_url = 'account:usertoken_list'
|
||||||
|
|
||||||
|
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||||
|
if not obj.pk:
|
||||||
|
obj.user = request.user
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(UserToken, 'delete')
|
||||||
|
class UserTokenDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = UserToken.objects.all()
|
||||||
|
default_return_url = 'account:usertoken_list'
|
@ -363,6 +363,7 @@ INSTALLED_APPS = [
|
|||||||
'taggit',
|
'taggit',
|
||||||
'timezone_field',
|
'timezone_field',
|
||||||
'core',
|
'core',
|
||||||
|
'account',
|
||||||
'circuits',
|
'circuits',
|
||||||
'dcim',
|
'dcim',
|
||||||
'ipam',
|
'ipam',
|
||||||
|
@ -1,19 +1,18 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls import include
|
from django.conf.urls import include
|
||||||
from django.urls import path, re_path
|
from django.urls import path
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.static import serve
|
from django.views.static import serve
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||||
|
|
||||||
|
from account.views import LoginView, LogoutView
|
||||||
from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
|
from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
|
||||||
from netbox.api.views import APIRootView, StatusView
|
from netbox.api.views import APIRootView, StatusView
|
||||||
from netbox.graphql.schema import schema
|
from netbox.graphql.schema import schema
|
||||||
from netbox.graphql.views import GraphQLView
|
from netbox.graphql.views import GraphQLView
|
||||||
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
|
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
|
||||||
from users.views import LoginView, LogoutView
|
|
||||||
from .admin import admin_site
|
from .admin import admin_site
|
||||||
|
|
||||||
|
|
||||||
_patterns = [
|
_patterns = [
|
||||||
|
|
||||||
# Base views
|
# Base views
|
||||||
@ -37,7 +36,7 @@ _patterns = [
|
|||||||
path('wireless/', include('wireless.urls')),
|
path('wireless/', include('wireless.urls')),
|
||||||
|
|
||||||
# Current user views
|
# Current user views
|
||||||
path('user/', include('users.account_urls')),
|
path('user/', include('account.urls')),
|
||||||
|
|
||||||
# HTMX views
|
# HTMX views
|
||||||
path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),
|
path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{% extends 'users/account/base.html' %}
|
{% extends 'account/base.html' %}
|
||||||
{% load buttons %}
|
{% load buttons %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load render_table from django_tables2 %}
|
{% load render_table from django_tables2 %}
|
||||||
@ -7,7 +7,6 @@
|
|||||||
{% block title %}{% trans "Bookmarks" %}{% endblock %}
|
{% block title %}{% trans "Bookmarks" %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<form method="post" class="form form-horizontal">
|
<form method="post" class="form form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="return_url" value="{% url 'account:bookmarks' %}" />
|
<input type="hidden" name="return_url" value="{% url 'account:bookmarks' %}" />
|
21
netbox/templates/account/password.html
Normal file
21
netbox/templates/account/password.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{% extends 'account/base.html' %}
|
||||||
|
{% load form_helpers %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Change Password" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form action="." method="post" class="form form-horizontal col-md-8 offset-md-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field-group">
|
||||||
|
<h5 class="text-center">{% trans "Password" %}</h5>
|
||||||
|
{% render_field form.old_password %}
|
||||||
|
{% render_field form.new_password1 %}
|
||||||
|
{% render_field form.new_password2 %}
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<a href="{% url 'account:profile' %}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||||
|
<button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
@ -1,4 +1,4 @@
|
|||||||
{% extends 'users/account/base.html' %}
|
{% extends 'account/base.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load form_helpers %}
|
{% load form_helpers %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
@ -1,4 +1,4 @@
|
|||||||
{% extends 'users/account/base.html' %}
|
{% extends 'account/base.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load render_table from django_tables2 %}
|
{% load render_table from django_tables2 %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
26
netbox/templates/account/token_list.html
Normal file
26
netbox/templates/account/token_list.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{% extends 'account/base.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "My API Tokens" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-12 text-end">
|
||||||
|
<a href="{% url 'account:usertoken_add' %}" class="btn btn-sm btn-primary my-3">
|
||||||
|
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add a Token" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body table-responsive">
|
||||||
|
{% render_table table 'inc/table.html' %}
|
||||||
|
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -1,21 +0,0 @@
|
|||||||
{% extends 'users/account/base.html' %}
|
|
||||||
{% load form_helpers %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block title %}{% trans "Change Password" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<form action="." method="post" class="form form-horizontal col-md-8 offset-md-2">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="field-group">
|
|
||||||
<h5 class="text-center">{% trans "Password" %}</h5>
|
|
||||||
{% render_field form.old_password %}
|
|
||||||
{% render_field form.new_password1 %}
|
|
||||||
{% render_field form.new_password2 %}
|
|
||||||
</div>
|
|
||||||
<div class="text-end">
|
|
||||||
<a href="{% url 'account:profile' %}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
|
||||||
<button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
@ -1,26 +0,0 @@
|
|||||||
{% extends 'users/account/base.html' %}
|
|
||||||
{% load helpers %}
|
|
||||||
{% load render_table from django_tables2 %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block title %}{% trans "My API Tokens" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col col-md-12 text-end">
|
|
||||||
<a href="{% url 'account:usertoken_add' %}" class="btn btn-sm btn-primary my-3">
|
|
||||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add a Token" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col col-md-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body table-responsive">
|
|
||||||
{% render_table table 'inc/table.html' %}
|
|
||||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
@ -26,7 +26,6 @@ __all__ = (
|
|||||||
'ObjectPermission',
|
'ObjectPermission',
|
||||||
'Token',
|
'Token',
|
||||||
'UserConfig',
|
'UserConfig',
|
||||||
'UserToken',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -322,18 +321,6 @@ class Token(models.Model):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class UserToken(Token):
|
|
||||||
"""
|
|
||||||
Proxy model for users to manage their own API tokens.
|
|
||||||
"""
|
|
||||||
class Meta:
|
|
||||||
proxy = True
|
|
||||||
verbose_name = 'token'
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse('account:usertoken', args=[self.pk])
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Permissions
|
# Permissions
|
||||||
#
|
#
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from account.tables import UserTokenTable
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserToken
|
from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'GroupTable',
|
'GroupTable',
|
||||||
@ -12,58 +13,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
TOKEN = """<samp><span id="token_{{ record.pk }}">{{ record }}</span></samp>"""
|
|
||||||
|
|
||||||
ALLOWED_IPS = """{{ value|join:", " }}"""
|
|
||||||
|
|
||||||
COPY_BUTTON = """
|
|
||||||
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
|
|
||||||
{% copy_content record.pk prefix="token_" color="success" %}
|
|
||||||
{% endif %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class UserTokenTable(NetBoxTable):
|
|
||||||
"""
|
|
||||||
Table for users to manager their own API tokens under account views.
|
|
||||||
"""
|
|
||||||
key = columns.TemplateColumn(
|
|
||||||
verbose_name=_('Key'),
|
|
||||||
template_code=TOKEN,
|
|
||||||
)
|
|
||||||
write_enabled = columns.BooleanColumn(
|
|
||||||
verbose_name=_('Write Enabled')
|
|
||||||
)
|
|
||||||
created = columns.DateColumn(
|
|
||||||
verbose_name=_('Created'),
|
|
||||||
)
|
|
||||||
expires = columns.DateColumn(
|
|
||||||
verbose_name=_('Expires'),
|
|
||||||
)
|
|
||||||
last_used = columns.DateTimeColumn(
|
|
||||||
verbose_name=_('Last Used'),
|
|
||||||
)
|
|
||||||
allowed_ips = columns.TemplateColumn(
|
|
||||||
verbose_name=_('Allowed IPs'),
|
|
||||||
template_code=ALLOWED_IPS
|
|
||||||
)
|
|
||||||
# TODO: Fix permissions evaluation & viewname resolution
|
|
||||||
actions = columns.ActionsColumn(
|
|
||||||
actions=('edit', 'delete'),
|
|
||||||
extra_buttons=COPY_BUTTON
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
|
||||||
model = UserToken
|
|
||||||
fields = (
|
|
||||||
'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TokenTable(UserTokenTable):
|
class TokenTable(UserTokenTable):
|
||||||
"""
|
|
||||||
General-purpose table for API token management.
|
|
||||||
"""
|
|
||||||
user = tables.Column(
|
user = tables.Column(
|
||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name=_('User')
|
verbose_name=_('User')
|
||||||
|
@ -1,298 +1,11 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
||||||
from django.contrib.auth.models import update_last_login
|
|
||||||
from django.contrib.auth.signals import user_logged_in
|
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.http import HttpResponseRedirect
|
|
||||||
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
|
|
||||||
from django.views.decorators.debug import sensitive_post_parameters
|
|
||||||
from django.views.generic import View
|
|
||||||
from social_core.backends.utils import load_backends
|
|
||||||
|
|
||||||
from extras.models import Bookmark, ObjectChange
|
from extras.models import ObjectChange
|
||||||
from extras.tables import BookmarkTable, ObjectChangeTable
|
from extras.tables import ObjectChangeTable
|
||||||
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
|
||||||
from netbox.config import get_config
|
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.forms import ConfirmationForm
|
|
||||||
from utilities.views import register_model_view
|
from utilities.views import register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserConfig, UserToken
|
from .models import NetBoxGroup, NetBoxUser, ObjectPermission, Token
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Login/logout
|
|
||||||
#
|
|
||||||
|
|
||||||
class LoginView(View):
|
|
||||||
"""
|
|
||||||
Perform user authentication via the web UI.
|
|
||||||
"""
|
|
||||||
template_name = 'login.html'
|
|
||||||
|
|
||||||
@method_decorator(sensitive_post_parameters('password'))
|
|
||||||
def dispatch(self, *args, **kwargs):
|
|
||||||
return super().dispatch(*args, **kwargs)
|
|
||||||
|
|
||||||
def gen_auth_data(self, name, url, params):
|
|
||||||
display_name, icon_name = get_auth_backend_display(name)
|
|
||||||
return {
|
|
||||||
'display_name': display_name,
|
|
||||||
'icon_name': icon_name,
|
|
||||||
'url': f'{url}?{urlencode(params)}',
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_auth_backends(self, request):
|
|
||||||
auth_backends = []
|
|
||||||
saml_idps = get_saml_idps()
|
|
||||||
|
|
||||||
for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
|
|
||||||
url = reverse('social:begin', args=[name])
|
|
||||||
params = {}
|
|
||||||
if next := request.GET.get('next'):
|
|
||||||
params['next'] = next
|
|
||||||
if name.lower() == 'saml' and saml_idps:
|
|
||||||
for idp in saml_idps:
|
|
||||||
params['idp'] = idp
|
|
||||||
data = self.gen_auth_data(name, url, params)
|
|
||||||
data['display_name'] = f'{data["display_name"]} ({idp})'
|
|
||||||
auth_backends.append(data)
|
|
||||||
else:
|
|
||||||
auth_backends.append(self.gen_auth_data(name, url, params))
|
|
||||||
|
|
||||||
return auth_backends
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
form = forms.LoginForm(request)
|
|
||||||
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
logger = logging.getLogger('netbox.auth.login')
|
|
||||||
return self.redirect_to_next(request, logger)
|
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
|
||||||
'form': form,
|
|
||||||
'auth_backends': self.get_auth_backends(request),
|
|
||||||
})
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
logger = logging.getLogger('netbox.auth.login')
|
|
||||||
form = forms.LoginForm(request, data=request.POST)
|
|
||||||
|
|
||||||
if form.is_valid():
|
|
||||||
logger.debug("Login form validation was successful")
|
|
||||||
|
|
||||||
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
|
|
||||||
# last_login time upon authentication.
|
|
||||||
if get_config().MAINTENANCE_MODE:
|
|
||||||
logger.warning("Maintenance mode enabled: disabling update of most recent login time")
|
|
||||||
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
|
|
||||||
|
|
||||||
# Authenticate user
|
|
||||||
auth_login(request, form.get_user())
|
|
||||||
logger.info(f"User {request.user} successfully authenticated")
|
|
||||||
messages.success(request, f"Logged in as {request.user}.")
|
|
||||||
|
|
||||||
# Ensure the user has a UserConfig defined. (This should normally be handled by
|
|
||||||
# create_userconfig() on user creation.)
|
|
||||||
if not hasattr(request.user, 'config'):
|
|
||||||
config = get_config()
|
|
||||||
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
|
|
||||||
|
|
||||||
return self.redirect_to_next(request, logger)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
|
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
|
||||||
'form': form,
|
|
||||||
'auth_backends': self.get_auth_backends(request),
|
|
||||||
})
|
|
||||||
|
|
||||||
def redirect_to_next(self, request, logger):
|
|
||||||
data = request.POST if request.method == "POST" else request.GET
|
|
||||||
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
|
|
||||||
|
|
||||||
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
|
|
||||||
logger.debug(f"Redirecting user to {redirect_url}")
|
|
||||||
else:
|
|
||||||
if redirect_url:
|
|
||||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
|
|
||||||
redirect_url = reverse('home')
|
|
||||||
|
|
||||||
return HttpResponseRedirect(redirect_url)
|
|
||||||
|
|
||||||
|
|
||||||
class LogoutView(View):
|
|
||||||
"""
|
|
||||||
Deauthenticate a web user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
logger = logging.getLogger('netbox.auth.logout')
|
|
||||||
|
|
||||||
# Log out the user
|
|
||||||
username = request.user
|
|
||||||
auth_logout(request)
|
|
||||||
logger.info(f"User {username} has logged out")
|
|
||||||
messages.info(request, "You have logged out.")
|
|
||||||
|
|
||||||
# Delete session key cookie (if set) upon logout
|
|
||||||
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
|
|
||||||
response.delete_cookie('session_key')
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# User profiles
|
|
||||||
#
|
|
||||||
|
|
||||||
class ProfileView(LoginRequiredMixin, View):
|
|
||||||
template_name = 'users/account/profile.html'
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
|
|
||||||
# Compile changelog table
|
|
||||||
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
|
||||||
user=request.user
|
|
||||||
).prefetch_related(
|
|
||||||
'changed_object_type'
|
|
||||||
)[:20]
|
|
||||||
changelog_table = ObjectChangeTable(changelog)
|
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
|
||||||
'changelog_table': changelog_table,
|
|
||||||
'active_tab': 'profile',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class UserConfigView(LoginRequiredMixin, View):
|
|
||||||
template_name = 'users/account/preferences.html'
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
userconfig = request.user.config
|
|
||||||
form = forms.UserConfigForm(instance=userconfig)
|
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
|
||||||
'form': form,
|
|
||||||
'active_tab': 'preferences',
|
|
||||||
})
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
userconfig = request.user.config
|
|
||||||
form = forms.UserConfigForm(request.POST, instance=userconfig)
|
|
||||||
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
|
|
||||||
messages.success(request, "Your preferences have been updated.")
|
|
||||||
return redirect('account:preferences')
|
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
|
||||||
'form': form,
|
|
||||||
'active_tab': 'preferences',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordView(LoginRequiredMixin, View):
|
|
||||||
template_name = 'users/account/password.html'
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
# LDAP users cannot change their password here
|
|
||||||
if getattr(request.user, 'ldap_username', None):
|
|
||||||
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
|
|
||||||
return redirect('account:profile')
|
|
||||||
|
|
||||||
form = forms.PasswordChangeForm(user=request.user)
|
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
|
||||||
'form': form,
|
|
||||||
'active_tab': 'password',
|
|
||||||
})
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
form = forms.PasswordChangeForm(user=request.user, data=request.POST)
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
update_session_auth_hash(request, form.user)
|
|
||||||
messages.success(request, "Your password has been changed successfully.")
|
|
||||||
return redirect('account:profile')
|
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
|
||||||
'form': form,
|
|
||||||
'active_tab': 'change_password',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Bookmarks
|
|
||||||
#
|
|
||||||
|
|
||||||
class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
|
|
||||||
table = BookmarkTable
|
|
||||||
template_name = 'users/account/bookmarks.html'
|
|
||||||
|
|
||||||
def get_queryset(self, request):
|
|
||||||
return Bookmark.objects.filter(user=request.user)
|
|
||||||
|
|
||||||
def get_extra_context(self, request):
|
|
||||||
return {
|
|
||||||
'active_tab': 'bookmarks',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# User views for token management
|
|
||||||
#
|
|
||||||
|
|
||||||
class UserTokenListView(LoginRequiredMixin, View):
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
tokens = UserToken.objects.filter(user=request.user)
|
|
||||||
table = tables.UserTokenTable(tokens)
|
|
||||||
table.configure(request)
|
|
||||||
|
|
||||||
return render(request, 'users/account/token_list.html', {
|
|
||||||
'tokens': tokens,
|
|
||||||
'active_tab': 'api-tokens',
|
|
||||||
'table': table,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(UserToken)
|
|
||||||
class UserTokenView(LoginRequiredMixin, View):
|
|
||||||
|
|
||||||
def get(self, request, pk):
|
|
||||||
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
|
|
||||||
key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
|
|
||||||
|
|
||||||
return render(request, 'users/account/token.html', {
|
|
||||||
'object': token,
|
|
||||||
'key': key,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class UserTokenEditView(generic.ObjectEditView):
|
|
||||||
queryset = UserToken.objects.all()
|
|
||||||
form = forms.UserTokenForm
|
|
||||||
default_return_url = 'account:usertoken_list'
|
|
||||||
|
|
||||||
def alter_object(self, obj, request, url_args, url_kwargs):
|
|
||||||
if not obj.pk:
|
|
||||||
obj.user = request.user
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
class UserTokenDeleteView(generic.ObjectDeleteView):
|
|
||||||
queryset = UserToken.objects.all()
|
|
||||||
default_return_url = 'account:usertoken_list'
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
Loading…
Reference in New Issue
Block a user