Coverage for account/views.py: 41%

221 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import logging 

2 

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 

22 

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 

40 

41# 

42# Login/logout 

43# 

44 

45 

46class LoginView(View): 

47 """ 

48 Perform user authentication via the web UI. 

49 """ 

50 template_name = 'login.html' 

51 

52 @method_decorator(sensitive_post_parameters('password')) 

53 def dispatch(self, *args, **kwargs): 

54 return super().dispatch(*args, **kwargs) 

55 

56 def gen_auth_data(self, name, url, params): 

57 display_name, icon_source = get_auth_backend_display(name) 

58 

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 

66 

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 } 

74 

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 

81 

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)) 

95 

96 return auth_backends 

97 

98 def get(self, request): 

99 form = AuthenticationForm(request) 

100 

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 

105 

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 }) 

111 

112 def post(self, request): 

113 logger = logging.getLogger('netbox.auth.login') 

114 form = AuthenticationForm(request, data=request.POST) 

115 

116 if form.is_valid(): 

117 logger.debug("Login form validation was successful") 

118 

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') 

124 

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)) 

129 

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() 

135 

136 response = self.redirect_to_next(request, logger) 

137 

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 ) 

146 

147 return response 

148 

149 username = form['username'].value() 

150 logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}") 

151 

152 return render(request, self.template_name, { 

153 'form': form, 

154 'auth_backends': self.get_auth_backends(request), 

155 }) 

156 

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) 

160 

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') 

167 

168 return HttpResponseRedirect(redirect_url) 

169 

170 

171class LogoutView(View): 

172 """ 

173 Deauthenticate a web user. 

174 """ 

175 

176 def get(self, request): 

177 logger = logging.getLogger('netbox.auth.logout') 

178 

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.")) 

184 

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) 

189 

190 return response 

191 

192 

193class SocialAuthBeginView(View): 

194 """ 

195 Initiate authentication against a social auth (SSO) backend. 

196 

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. 

205 

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. 

212 

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) 

219 

220 def post(self, request, backend): 

221 response = social_auth_begin(request, backend) 

222 

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)}) 

231 

232 # Anything else (including the response to a client which has not asked for JSON) is passed 

233 # through unchanged. 

234 return response 

235 

236 

237# 

238# User profiles 

239# 

240 

241class ProfileView(LoginRequiredMixin, View): 

242 template_name = 'account/profile.html' 

243 

244 def get(self, request): 

245 

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) 

251 

252 return render(request, self.template_name, { 

253 'changelog_table': changelog_table, 

254 'active_tab': 'profile', 

255 }) 

256 

257 

258class UserConfigView(LoginRequiredMixin, View): 

259 template_name = 'account/preferences.html' 

260 

261 def get(self, request): 

262 userconfig = request.user.config 

263 form = forms.UserConfigForm(instance=userconfig) 

264 

265 return render(request, self.template_name, { 

266 'form': form, 

267 'active_tab': 'preferences', 

268 }) 

269 

270 def post(self, request): 

271 userconfig = request.user.config 

272 form = forms.UserConfigForm(request.POST, instance=userconfig) 

273 

274 if form.is_valid(): 

275 form.save() 

276 

277 messages.success(request, _("Your preferences have been updated.")) 

278 response = redirect('account:preferences') 

279 

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) 

290 

291 return response 

292 

293 return render(request, self.template_name, { 

294 'form': form, 

295 'active_tab': 'preferences', 

296 }) 

297 

298 

299class ChangePasswordView(LoginRequiredMixin, View): 

300 template_name = 'account/password.html' 

301 

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') 

307 

308 form = PasswordChangeForm(user=request.user) 

309 

310 return render(request, self.template_name, { 

311 'form': form, 

312 'active_tab': 'password', 

313 }) 

314 

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') 

322 

323 return render(request, self.template_name, { 

324 'form': form, 

325 'active_tab': 'change_password', 

326 }) 

327 

328 

329# 

330# Bookmarks 

331# 

332 

333class BookmarkListView(LoginRequiredMixin, generic.ObjectListView): 

334 table = BookmarkTable 

335 template_name = 'account/bookmarks.html' 

336 

337 def get_queryset(self, request): 

338 return Bookmark.objects.filter(user=request.user) 

339 

340 def get_extra_context(self, request): 

341 return { 

342 'active_tab': 'bookmarks', 

343 } 

344 

345 

346# 

347# Notifications & subscriptions 

348# 

349 

350class NotificationListView(LoginRequiredMixin, generic.ObjectListView): 

351 table = NotificationTable 

352 template_name = 'account/notifications.html' 

353 

354 def get_queryset(self, request): 

355 return request.user.notifications.all() 

356 

357 def get_extra_context(self, request): 

358 return { 

359 'active_tab': 'notifications', 

360 } 

361 

362 

363class SubscriptionListView(LoginRequiredMixin, generic.ObjectListView): 

364 table = SubscriptionTable 

365 template_name = 'account/subscriptions.html' 

366 

367 def get_queryset(self, request): 

368 return request.user.subscriptions.all() 

369 

370 def get_extra_context(self, request): 

371 return { 

372 'active_tab': 'subscriptions', 

373 } 

374 

375 

376# 

377# User views for token management 

378# 

379 

380class UserTokenListView(LoginRequiredMixin, View): 

381 

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) 

387 

388 return render(request, 'account/token_list.html', { 

389 'tokens': tokens, 

390 'active_tab': 'api-tokens', 

391 'table': table, 

392 }) 

393 

394 

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 ) 

411 

412 def get(self, request, pk): 

413 token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) 

414 

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 

420 

421 return render(request, 'users/token.html', { 

422 'object': token, 

423 'layout': self.layout, 

424 'token_auth_string': token_auth_string, 

425 }) 

426 

427 

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' 

434 

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 

441 

442 

443@register_model_view(UserToken, 'delete') 

444class UserTokenDeleteView(generic.ObjectDeleteView): 

445 queryset = UserToken.objects.all() 

446 default_return_url = 'account:usertoken_list'