mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00: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:
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
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('users', '0004_netboxgroup_netboxuser'),
|
||||
]
|
||||
@ -15,10 +17,10 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'token',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
'verbose_name': '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
|
||||
|
||||
app_name = 'account'
|
||||
@ -12,8 +13,6 @@ urlpatterns = [
|
||||
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
|
||||
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
|
||||
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>/edit/', views.UserTokenEditView.as_view(), name='usertoken_edit'),
|
||||
path('api-tokens/<int:pk>/delete/', views.UserTokenDeleteView.as_view(), name='usertoken_delete'),
|
||||
path('api-tokens/<int:pk>/', include(get_model_urls('account', 'usertoken'))),
|
||||
|
||||
]
|
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',
|
||||
'timezone_field',
|
||||
'core',
|
||||
'account',
|
||||
'circuits',
|
||||
'dcim',
|
||||
'ipam',
|
||||
|
@ -1,19 +1,18 @@
|
||||
from django.conf import settings
|
||||
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.static import serve
|
||||
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 netbox.api.views import APIRootView, StatusView
|
||||
from netbox.graphql.schema import schema
|
||||
from netbox.graphql.views import GraphQLView
|
||||
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
|
||||
from users.views import LoginView, LogoutView
|
||||
from .admin import admin_site
|
||||
|
||||
|
||||
_patterns = [
|
||||
|
||||
# Base views
|
||||
@ -37,7 +36,7 @@ _patterns = [
|
||||
path('wireless/', include('wireless.urls')),
|
||||
|
||||
# Current user views
|
||||
path('user/', include('users.account_urls')),
|
||||
path('user/', include('account.urls')),
|
||||
|
||||
# HTMX views
|
||||
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 helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
@ -7,7 +7,6 @@
|
||||
{% block title %}{% trans "Bookmarks" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<form method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<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 form_helpers %}
|
||||
{% load i18n %}
|
@ -1,4 +1,4 @@
|
||||
{% extends 'users/account/base.html' %}
|
||||
{% extends 'account/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% 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',
|
||||
'Token',
|
||||
'UserConfig',
|
||||
'UserToken',
|
||||
)
|
||||
|
||||
|
||||
@ -322,18 +321,6 @@ class Token(models.Model):
|
||||
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
|
||||
#
|
||||
|
@ -1,8 +1,9 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from account.tables import UserTokenTable
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserToken
|
||||
from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token
|
||||
|
||||
__all__ = (
|
||||
'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):
|
||||
"""
|
||||
General-purpose table for API token management.
|
||||
"""
|
||||
user = tables.Column(
|
||||
linkify=True,
|
||||
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.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.tables import BookmarkTable, ObjectChangeTable
|
||||
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
||||
from netbox.config import get_config
|
||||
from extras.models import ObjectChange
|
||||
from extras.tables import ObjectChangeTable
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.views import register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserConfig, UserToken
|
||||
|
||||
|
||||
#
|
||||
# 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'
|
||||
from .models import NetBoxGroup, NetBoxUser, ObjectPermission, Token
|
||||
|
||||
|
||||
#
|
||||
|
Reference in New Issue
Block a user