Coverage for account/views.py: 41%
221 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import logging
3from django.conf import settings
4from django.contrib import messages
5from django.contrib.auth import login as auth_login
6from django.contrib.auth import logout as auth_logout
7from django.contrib.auth import update_session_auth_hash
8from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
9from django.contrib.auth.mixins import LoginRequiredMixin
10from django.contrib.auth.models import update_last_login
11from django.contrib.auth.signals import user_logged_in
12from django.http import HttpResponseRedirect, JsonResponse
13from django.shortcuts import get_object_or_404, redirect, render, resolve_url
14from django.urls import reverse, reverse_lazy
15from django.utils.decorators import method_decorator
16from django.utils.translation import gettext_lazy as _
17from django.views.decorators.cache import never_cache
18from django.views.decorators.debug import sensitive_post_parameters
19from django.views.generic import View
20from social_core.backends.utils import load_backends
21from social_django.views import auth as social_auth_begin
23from account.models import UserToken
24from core.models import ObjectChange
25from core.tables import ObjectChangeTable
26from extras.models import Bookmark
27from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
28from netbox.authentication import get_auth_backend_display, get_saml_idps
29from netbox.config import get_config
30from netbox.ui import layout
31from netbox.ui.breadcrumbs import Breadcrumb
32from netbox.views import generic
33from users import forms
34from users.models import UserConfig
35from users.tables import TokenTable
36from users.ui.panels import TokenExamplePanel, TokenPanel
37from utilities.request import safe_for_redirect
38from utilities.string import remove_linebreaks
39from utilities.views import register_model_view
41#
42# Login/logout
43#
46class LoginView(View):
47 """
48 Perform user authentication via the web UI.
49 """
50 template_name = 'login.html'
52 @method_decorator(sensitive_post_parameters('password'))
53 def dispatch(self, *args, **kwargs):
54 return super().dispatch(*args, **kwargs)
56 def gen_auth_data(self, name, url, params):
57 display_name, icon_source = get_auth_backend_display(name)
59 icon_name = None
60 icon_img = None
61 if icon_source:
62 if '://' in icon_source:
63 icon_img = icon_source
64 else:
65 icon_name = icon_source
67 return {
68 'display_name': display_name,
69 'icon_name': icon_name,
70 'icon_img': icon_img,
71 'url': url,
72 'params': dict(params),
73 }
75 def get_auth_backends(self, request):
76 auth_backends = []
77 saml_idps = get_saml_idps()
78 # The login page is re-rendered by post() when authentication fails, in which case the
79 # post-login URL is found in the POST data (as with redirect_to_next() below).
80 request_data = request.POST if request.method == 'POST' else request.GET
82 for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys(): 82 ↛ 83line 82 didn't jump to line 83 because the loop on line 82 never started
83 url = reverse('social_auth_begin', args=[name])
84 params = {}
85 if next := request_data.get('next'):
86 params['next'] = next
87 if name.lower() == 'saml' and saml_idps:
88 for idp in saml_idps:
89 params['idp'] = idp
90 data = self.gen_auth_data(name, url, params)
91 data['display_name'] = f'{data["display_name"]} ({idp})'
92 auth_backends.append(data)
93 else:
94 auth_backends.append(self.gen_auth_data(name, url, params))
96 return auth_backends
98 def get(self, request):
99 form = AuthenticationForm(request)
101 if request.user.is_authenticated: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 logger = logging.getLogger('netbox.auth.login')
103 return self.redirect_to_next(request, logger)
104 login_form_hidden = settings.LOGIN_FORM_HIDDEN
106 return render(request, self.template_name, {
107 'form': form,
108 'auth_backends': self.get_auth_backends(request),
109 'login_form_hidden': login_form_hidden,
110 })
112 def post(self, request):
113 logger = logging.getLogger('netbox.auth.login')
114 form = AuthenticationForm(request, data=request.POST)
116 if form.is_valid():
117 logger.debug("Login form validation was successful")
119 # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
120 # last_login time upon authentication.
121 if get_config().MAINTENANCE_MODE:
122 logger.warning("Maintenance mode enabled: disabling update of most recent login time")
123 user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
125 # Authenticate user
126 auth_login(request, form.get_user())
127 logger.info(f"User {request.user} successfully authenticated")
128 messages.success(request, _("Logged in as {user}.").format(user=request.user))
130 # Ensure the user has a UserConfig defined. (This should normally be handled by
131 # create_userconfig() on user creation.)
132 if not hasattr(request.user, 'config'):
133 request.user.config = get_config()
134 UserConfig(user=request.user, data=request.user.config.DEFAULT_USER_PREFERENCES).save()
136 response = self.redirect_to_next(request, logger)
138 # Set the user's preferred language (if any)
139 if language := request.user.config.get('locale.language'):
140 response.set_cookie(
141 key=settings.LANGUAGE_COOKIE_NAME,
142 value=language,
143 max_age=request.session.get_expiry_age(),
144 secure=settings.SESSION_COOKIE_SECURE,
145 )
147 return response
149 username = form['username'].value()
150 logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
152 return render(request, self.template_name, {
153 'form': form,
154 'auth_backends': self.get_auth_backends(request),
155 })
157 def redirect_to_next(self, request, logger):
158 data = request.POST if request.method == "POST" else request.GET
159 redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
161 if redirect_url and safe_for_redirect(redirect_url):
162 logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
163 else:
164 if redirect_url:
165 logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {remove_linebreaks(redirect_url)}")
166 redirect_url = reverse('home')
168 return HttpResponseRedirect(redirect_url)
171class LogoutView(View):
172 """
173 Deauthenticate a web user.
174 """
176 def get(self, request):
177 logger = logging.getLogger('netbox.auth.logout')
179 # Log out the user
180 username = request.user
181 auth_logout(request)
182 logger.info(f"User {username} has logged out")
183 messages.info(request, _("You have logged out."))
185 # Delete session key & language cookies (if set) upon logout
186 response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
187 response.delete_cookie('session_key')
188 response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
190 return response
193class SocialAuthBeginView(View):
194 """
195 Initiate authentication against a social auth (SSO) backend.
197 This wraps python-social-auth's "begin" view, which responds with an HTTP redirect to the
198 identity provider. Chromium-based browsers evaluate the CSP `form-action` directive against
199 every hop in a form submission's redirect chain, so a deployment which serves NetBox with
200 `form-action 'self'` (a common reverse proxy default) blocks that redirect and the SSO button
201 appears to do nothing. A client which asks for JSON is given the identity provider's URL in the
202 response body instead, and navigates to it itself: `form-action` does not govern a navigation
203 initiated by a script. Any other client (e.g. a browser with JavaScript disabled) receives the
204 unmodified response from python-social-auth.
206 A backend which does not redirect (`uses_redirect()` is False, as for OpenID 2.0) renders its
207 own HTML instead, which is returned in the response body for the client to render in place so
208 that it need not repeat the request. This is not a way around `form-action`: that document
209 carries a form which submits itself to the identity provider, and such a submission is governed
210 by the policy wherever the document is rendered. Deployments using one of these backends still
211 require a `form-action` which admits the identity provider.
213 The underlying view is reused as-is so that CSRF protection, the callback URL, and the session
214 state recorded for the identity provider all remain identical to a direct form submission.
215 """
216 @method_decorator(never_cache)
217 def dispatch(self, *args, **kwargs):
218 return super().dispatch(*args, **kwargs)
220 def post(self, request, backend):
221 response = social_auth_begin(request, backend)
223 if 'application/json' in request.headers.get('Accept', ''):
224 if url := response.headers.get('Location'):
225 return JsonResponse({'url': url})
226 if response.status_code == 200:
227 # Some backends render an HTML form (which submits itself to the identity provider)
228 # rather than redirecting. Hand that document to the client to render, so that it
229 # need not repeat the request and initiate the login a second time.
230 return JsonResponse({'html': response.content.decode(response.charset)})
232 # Anything else (including the response to a client which has not asked for JSON) is passed
233 # through unchanged.
234 return response
237#
238# User profiles
239#
241class ProfileView(LoginRequiredMixin, View):
242 template_name = 'account/profile.html'
244 def get(self, request):
246 # Compile changelog table
247 changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(user=request.user)[:20]
248 changelog_table = ObjectChangeTable(changelog)
249 changelog_table.orderable = False
250 changelog_table.configure(request)
252 return render(request, self.template_name, {
253 'changelog_table': changelog_table,
254 'active_tab': 'profile',
255 })
258class UserConfigView(LoginRequiredMixin, View):
259 template_name = 'account/preferences.html'
261 def get(self, request):
262 userconfig = request.user.config
263 form = forms.UserConfigForm(instance=userconfig)
265 return render(request, self.template_name, {
266 'form': form,
267 'active_tab': 'preferences',
268 })
270 def post(self, request):
271 userconfig = request.user.config
272 form = forms.UserConfigForm(request.POST, instance=userconfig)
274 if form.is_valid():
275 form.save()
277 messages.success(request, _("Your preferences have been updated."))
278 response = redirect('account:preferences')
280 # Set/clear language cookie
281 if language := form.cleaned_data['locale.language']:
282 response.set_cookie(
283 key=settings.LANGUAGE_COOKIE_NAME,
284 value=language,
285 max_age=request.session.get_expiry_age(),
286 secure=settings.SESSION_COOKIE_SECURE,
287 )
288 else:
289 response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
291 return response
293 return render(request, self.template_name, {
294 'form': form,
295 'active_tab': 'preferences',
296 })
299class ChangePasswordView(LoginRequiredMixin, View):
300 template_name = 'account/password.html'
302 def get(self, request):
303 # LDAP users cannot change their password here
304 if getattr(request.user, 'ldap_username', None):
305 messages.warning(request, _("LDAP-authenticated user credentials cannot be changed within NetBox."))
306 return redirect('account:profile')
308 form = PasswordChangeForm(user=request.user)
310 return render(request, self.template_name, {
311 'form': form,
312 'active_tab': 'password',
313 })
315 def post(self, request):
316 form = PasswordChangeForm(user=request.user, data=request.POST)
317 if form.is_valid():
318 form.save()
319 update_session_auth_hash(request, form.user)
320 messages.success(request, _("Your password has been changed successfully."))
321 return redirect('account:profile')
323 return render(request, self.template_name, {
324 'form': form,
325 'active_tab': 'change_password',
326 })
329#
330# Bookmarks
331#
333class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
334 table = BookmarkTable
335 template_name = 'account/bookmarks.html'
337 def get_queryset(self, request):
338 return Bookmark.objects.filter(user=request.user)
340 def get_extra_context(self, request):
341 return {
342 'active_tab': 'bookmarks',
343 }
346#
347# Notifications & subscriptions
348#
350class NotificationListView(LoginRequiredMixin, generic.ObjectListView):
351 table = NotificationTable
352 template_name = 'account/notifications.html'
354 def get_queryset(self, request):
355 return request.user.notifications.all()
357 def get_extra_context(self, request):
358 return {
359 'active_tab': 'notifications',
360 }
363class SubscriptionListView(LoginRequiredMixin, generic.ObjectListView):
364 table = SubscriptionTable
365 template_name = 'account/subscriptions.html'
367 def get_queryset(self, request):
368 return request.user.subscriptions.all()
370 def get_extra_context(self, request):
371 return {
372 'active_tab': 'subscriptions',
373 }
376#
377# User views for token management
378#
380class UserTokenListView(LoginRequiredMixin, View):
382 def get(self, request):
383 tokens = UserToken.objects.filter(user=request.user)
384 table = TokenTable(tokens)
385 table.columns.hide('user')
386 table.configure(request)
388 return render(request, 'account/token_list.html', {
389 'tokens': tokens,
390 'active_tab': 'api-tokens',
391 'table': table,
392 })
395@register_model_view(UserToken)
396class UserTokenView(LoginRequiredMixin, View):
397 layout = layout.SimpleLayout(
398 # The global UserToken list view is admin-only, so substitute the user's personal token list
399 # for the default root breadcrumb.
400 root_breadcrumb=False,
401 breadcrumbs=[
402 Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list')),
403 ],
404 left_panels=[
405 TokenPanel(),
406 ],
407 right_panels=[
408 TokenExamplePanel(),
409 ],
410 )
412 def get(self, request, pk):
413 token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
415 # Pop a one-time plaintext value (set by UserTokenEditView.post_save when a token is first created) and
416 # assemble the full HTTP authorization string for display. The plaintext is never persisted; popping
417 # ensures the banner only renders once.
418 plaintext = request.session.pop(f'_token_plaintext_{token.pk}', None)
419 token_auth_string = f'{token.get_auth_header_prefix()}{plaintext}' if plaintext else None
421 return render(request, 'users/token.html', {
422 'object': token,
423 'layout': self.layout,
424 'token_auth_string': token_auth_string,
425 })
428@register_model_view(UserToken, 'edit')
429class UserTokenEditView(generic.ObjectEditView):
430 queryset = UserToken.objects.all()
431 form = forms.UserTokenForm
432 template_name = 'account/usertoken_edit.html'
433 default_return_url = 'account:usertoken_list'
435 def alter_object(self, obj, request, url_args, url_kwargs):
436 if not obj.pk:
437 obj.user = request.user
438 # Attach the request so that UserTokenForm.save() can stash the newly-generated plaintext on the session.
439 obj._request = request
440 return obj
443@register_model_view(UserToken, 'delete')
444class UserTokenDeleteView(generic.ObjectDeleteView):
445 queryset = UserToken.objects.all()
446 default_return_url = 'account:usertoken_list'