From 80376abedf563cb7179e1df5a0f85960365c1439 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 31 Jul 2023 09:22:04 -0400 Subject: [PATCH] Closes #13309: Introduce the account app (#13310) * Introduce 'accounts' app for user-specific views & resources * Move UserTokenTable to account app * Move login & logout views to account app --- netbox/account/__init__.py | 0 .../migrations/0001_initial.py} | 6 +- netbox/account/migrations/__init__.py | 0 netbox/account/models.py | 15 + netbox/account/tables.py | 55 ++++ .../account_urls.py => account/urls.py} | 7 +- netbox/account/views.py | 298 ++++++++++++++++++ netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 7 +- .../templates/{users => }/account/base.html | 0 .../{users => }/account/bookmarks.html | 3 +- netbox/templates/account/password.html | 21 ++ .../{users => }/account/preferences.html | 2 +- .../{users => }/account/profile.html | 2 +- .../templates/{users => }/account/token.html | 0 netbox/templates/account/token_list.html | 26 ++ netbox/templates/users/account/password.html | 21 -- .../templates/users/account/token_list.html | 26 -- netbox/users/models.py | 13 - netbox/users/tables.py | 54 +--- netbox/users/views.py | 293 +---------------- 21 files changed, 434 insertions(+), 416 deletions(-) create mode 100644 netbox/account/__init__.py rename netbox/{users/migrations/0005_usertoken.py => account/migrations/0001_initial.py} (87%) create mode 100644 netbox/account/migrations/__init__.py create mode 100644 netbox/account/models.py create mode 100644 netbox/account/tables.py rename netbox/{users/account_urls.py => account/urls.py} (64%) create mode 100644 netbox/account/views.py rename netbox/templates/{users => }/account/base.html (100%) rename netbox/templates/{users => }/account/bookmarks.html (95%) create mode 100644 netbox/templates/account/password.html rename netbox/templates/{users => }/account/preferences.html (98%) rename netbox/templates/{users => }/account/profile.html (98%) rename netbox/templates/{users => }/account/token.html (100%) create mode 100644 netbox/templates/account/token_list.html delete mode 100644 netbox/templates/users/account/password.html delete mode 100644 netbox/templates/users/account/token_list.html diff --git a/netbox/account/__init__.py b/netbox/account/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/users/migrations/0005_usertoken.py b/netbox/account/migrations/0001_initial.py similarity index 87% rename from netbox/users/migrations/0005_usertoken.py rename to netbox/account/migrations/0001_initial.py index c6aef0590..72c079565 100644 --- a/netbox/users/migrations/0005_usertoken.py +++ b/netbox/account/migrations/0001_initial.py @@ -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',), ), diff --git a/netbox/account/migrations/__init__.py b/netbox/account/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/account/models.py b/netbox/account/models.py new file mode 100644 index 000000000..5d6575040 --- /dev/null +++ b/netbox/account/models.py @@ -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]) diff --git a/netbox/account/tables.py b/netbox/account/tables.py new file mode 100644 index 000000000..6655a7f82 --- /dev/null +++ b/netbox/account/tables.py @@ -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 = """{{ record }}""" + +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', + ) diff --git a/netbox/users/account_urls.py b/netbox/account/urls.py similarity index 64% rename from netbox/users/account_urls.py rename to netbox/account/urls.py index 4c65a2a42..1276dce40 100644 --- a/netbox/users/account_urls.py +++ b/netbox/account/urls.py @@ -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//', views.UserTokenView.as_view(), name='usertoken'), - path('api-tokens//edit/', views.UserTokenEditView.as_view(), name='usertoken_edit'), - path('api-tokens//delete/', views.UserTokenDeleteView.as_view(), name='usertoken_delete'), + path('api-tokens//', include(get_model_urls('account', 'usertoken'))), ] diff --git a/netbox/account/views.py b/netbox/account/views.py new file mode 100644 index 000000000..3156b2102 --- /dev/null +++ b/netbox/account/views.py @@ -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' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 70441988e..57ce2f2aa 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -363,6 +363,7 @@ INSTALLED_APPS = [ 'taggit', 'timezone_field', 'core', + 'account', 'circuits', 'dcim', 'ipam', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index e44e9e08e..595a9001f 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -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'), diff --git a/netbox/templates/users/account/base.html b/netbox/templates/account/base.html similarity index 100% rename from netbox/templates/users/account/base.html rename to netbox/templates/account/base.html diff --git a/netbox/templates/users/account/bookmarks.html b/netbox/templates/account/bookmarks.html similarity index 95% rename from netbox/templates/users/account/bookmarks.html rename to netbox/templates/account/bookmarks.html index 18fbc169b..4b90beed3 100644 --- a/netbox/templates/users/account/bookmarks.html +++ b/netbox/templates/account/bookmarks.html @@ -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 %} -
{% csrf_token %} diff --git a/netbox/templates/account/password.html b/netbox/templates/account/password.html new file mode 100644 index 000000000..055be4879 --- /dev/null +++ b/netbox/templates/account/password.html @@ -0,0 +1,21 @@ +{% extends 'account/base.html' %} +{% load form_helpers %} +{% load i18n %} + +{% block title %}{% trans "Change Password" %}{% endblock %} + +{% block content %} + + {% csrf_token %} +
+
{% trans "Password" %}
+ {% render_field form.old_password %} + {% render_field form.new_password1 %} + {% render_field form.new_password2 %} +
+
+ {% trans "Cancel" %} + +
+
+{% endblock %} diff --git a/netbox/templates/users/account/preferences.html b/netbox/templates/account/preferences.html similarity index 98% rename from netbox/templates/users/account/preferences.html rename to netbox/templates/account/preferences.html index 37ecc7102..e31baf0e7 100644 --- a/netbox/templates/users/account/preferences.html +++ b/netbox/templates/account/preferences.html @@ -1,4 +1,4 @@ -{% extends 'users/account/base.html' %} +{% extends 'account/base.html' %} {% load helpers %} {% load form_helpers %} {% load i18n %} diff --git a/netbox/templates/users/account/profile.html b/netbox/templates/account/profile.html similarity index 98% rename from netbox/templates/users/account/profile.html rename to netbox/templates/account/profile.html index fc93db366..cb699072c 100644 --- a/netbox/templates/users/account/profile.html +++ b/netbox/templates/account/profile.html @@ -1,4 +1,4 @@ -{% extends 'users/account/base.html' %} +{% extends 'account/base.html' %} {% load helpers %} {% load render_table from django_tables2 %} {% load i18n %} diff --git a/netbox/templates/users/account/token.html b/netbox/templates/account/token.html similarity index 100% rename from netbox/templates/users/account/token.html rename to netbox/templates/account/token.html diff --git a/netbox/templates/account/token_list.html b/netbox/templates/account/token_list.html new file mode 100644 index 000000000..cfcdeb537 --- /dev/null +++ b/netbox/templates/account/token_list.html @@ -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 %} + +
+
+
+
+ {% render_table table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/users/account/password.html b/netbox/templates/users/account/password.html deleted file mode 100644 index b086205d1..000000000 --- a/netbox/templates/users/account/password.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends 'users/account/base.html' %} -{% load form_helpers %} -{% load i18n %} - -{% block title %}{% trans "Change Password" %}{% endblock %} - -{% block content %} -
- {% csrf_token %} -
-
{% trans "Password" %}
- {% render_field form.old_password %} - {% render_field form.new_password1 %} - {% render_field form.new_password2 %} -
-
- {% trans "Cancel" %} - -
-
-{% endblock %} diff --git a/netbox/templates/users/account/token_list.html b/netbox/templates/users/account/token_list.html deleted file mode 100644 index ed58128a6..000000000 --- a/netbox/templates/users/account/token_list.html +++ /dev/null @@ -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 %} - -
-
-
-
- {% render_table table 'inc/table.html' %} - {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} -
-
-
-
-{% endblock %} diff --git a/netbox/users/models.py b/netbox/users/models.py index 0c95559ff..c9f932cdf 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -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 # diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 8069897b9..5a3210afe 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -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 = """{{ record }}""" - -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') diff --git a/netbox/users/views.py b/netbox/users/views.py index 62ce65588..7ff9a8a4d 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -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 #