Coverage for app/venv/lib/python3.14/site-packages/weblate/accounts/views.py: 23%

986 statements  

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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7import os 

8import re 

9from base64 import b32encode 

10from binascii import unhexlify 

11from collections import defaultdict 

12from datetime import timedelta 

13from typing import TYPE_CHECKING, Any, ClassVar 

14from urllib.parse import quote 

15 

16import qrcode 

17import qrcode.image.svg 

18import social_django.utils 

19from django.conf import settings 

20from django.contrib.auth import REDIRECT_FIELD_NAME, get_backends 

21from django.contrib.auth import login as auth_login 

22from django.contrib.auth import logout as auth_logout 

23from django.contrib.auth.decorators import login_required 

24from django.contrib.auth.views import LoginView, RedirectURLMixin 

25from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError 

26from django.core.mail.message import EmailMessage 

27from django.core.signing import BadSignature, SignatureExpired, TimestampSigner 

28from django.db import transaction 

29from django.db.models import Count, Q 

30from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse 

31from django.http.response import HttpResponseServerError 

32from django.middleware.csrf import rotate_token 

33from django.shortcuts import get_object_or_404, redirect, render 

34from django.template.loader import render_to_string 

35from django.urls import reverse 

36from django.utils import timezone 

37from django.utils.cache import patch_response_headers 

38from django.utils.decorators import method_decorator 

39from django.utils.functional import cached_property 

40from django.utils.http import urlencode 

41from django.utils.safestring import mark_safe 

42from django.utils.translation import gettext, gettext_lazy 

43from django.views.decorators.cache import never_cache 

44from django.views.decorators.csrf import csrf_exempt 

45from django.views.decorators.http import require_POST 

46from django.views.generic import ( 

47 DetailView, 

48 FormView, 

49 ListView, 

50 TemplateView, 

51 UpdateView, 

52 View, 

53) 

54from django_otp import DEVICE_ID_SESSION_KEY 

55from django_otp import login as otp_login 

56from django_otp.models import Device 

57from django_otp.plugins.otp_static.models import StaticDevice, StaticToken 

58from django_otp.plugins.otp_totp.models import TOTPDevice 

59from django_otp.util import random_hex 

60from django_otp_webauthn.exceptions import OTPWebAuthnApiError 

61from django_otp_webauthn.models import WebAuthnCredential 

62from django_otp_webauthn.views import ( 

63 BeginCredentialAuthenticationView, 

64 CompleteCredentialAuthenticationView, 

65) 

66from rest_framework.authtoken.models import Token 

67from social_core.actions import do_auth 

68from social_core.backends.base import BaseAuth 

69from social_core.exceptions import ( 

70 AuthAlreadyAssociated, 

71 AuthCanceled, 

72 AuthException, 

73 AuthFailed, 

74 AuthForbidden, 

75 AuthMissingParameter, 

76 AuthStateForbidden, 

77 AuthStateMissing, 

78 AuthTokenError, 

79 AuthUnreachableProvider, 

80 InvalidEmail, 

81 MissingBackend, 

82) 

83from social_django.utils import load_backend, load_strategy 

84from social_django.views import complete, disconnect 

85 

86from weblate.accounts.auth import WeblateUserBackend 

87from weblate.accounts.avatar import get_avatar_image, get_fallback_avatar_url 

88from weblate.accounts.forms import ( 

89 CommitForm, 

90 ContactForm, 

91 DashboardSettingsForm, 

92 EmailForm, 

93 EmptyConfirmForm, 

94 GroupAddForm, 

95 GroupRemoveForm, 

96 LanguagesForm, 

97 LoginForm, 

98 NotificationForm, 

99 OTPTokenForm, 

100 PasswordConfirmForm, 

101 ProfileForm, 

102 RegistrationForm, 

103 ResetForm, 

104 SetPasswordForm, 

105 SubscriptionForm, 

106 TOTPDeviceForm, 

107 TOTPTokenForm, 

108 UserForm, 

109 UserSearchForm, 

110 UserSettingsForm, 

111 WebAuthnTokenForm, 

112) 

113from weblate.accounts.models import AuditLog, Subscription, VerifiedEmail 

114from weblate.accounts.notifications import ( 

115 NOTIFICATIONS, 

116 NotificationFrequency, 

117 NotificationScope, 

118 get_email_headers, 

119 send_notification_email, 

120) 

121from weblate.accounts.pipeline import EmailAlreadyAssociated, UsernameAlreadyAssociated 

122from weblate.accounts.utils import ( 

123 SESSION_SECOND_FACTOR_SOCIAL, 

124 SESSION_SECOND_FACTOR_TOTP, 

125 SESSION_SECOND_FACTOR_USER, 

126 SESSION_WEBAUTHN_AUDIT, 

127 get_key_name, 

128 lock_user, 

129 remove_user, 

130) 

131from weblate.auth.forms import UserEditForm 

132from weblate.auth.models import ( 

133 AuthenticatedHttpRequest, 

134 Invitation, 

135 User, 

136 get_anonymous, 

137) 

138from weblate.auth.utils import format_address, get_auth_keys 

139from weblate.logger import LOGGER 

140from weblate.trans.models import Change, Component, Project, Suggestion, Translation 

141from weblate.trans.models.component import translation_prefetch_tasks 

142from weblate.trans.models.project import prefetch_project_flags 

143from weblate.trans.util import redirect_next 

144from weblate.utils import messages 

145from weblate.utils.errors import add_breadcrumb, report_error 

146from weblate.utils.ratelimit import check_rate_limit, session_ratelimit_post 

147from weblate.utils.request import get_ip_address, get_user_agent 

148from weblate.utils.stats import prefetch_stats 

149from weblate.utils.token import get_token 

150from weblate.utils.version import USER_AGENT 

151from weblate.utils.views import get_paginator, parse_path 

152from weblate.utils.zammad import ZammadError, submit_zammad_ticket 

153 

154if TYPE_CHECKING: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true

155 from django.forms import Form 

156 

157 from weblate.accounts.forms import ProfileBaseForm 

158 from weblate.accounts.types import DeviceType 

159 from weblate.auth.models import AuthenticatedHttpRequest 

160 

161CONTACT_TEMPLATE = """ 

162Message from %(name)s <%(email)s>: 

163 

164%(message)s 

165""" 

166 

167 

168MESSAGE_TEMPLATE = """ 

169{message} 

170 

171-- 

172User: {username} 

173IP address: {address} 

174User agent: {agent} 

175""" 

176 

177CONTACT_SUBJECTS = { 

178 "lang": "New language request", 

179 "reg": "Registration problems", 

180 "hosting": "Commercial hosting", 

181 "account": "Suspicious account activity", 

182 "trial": "Trial extension request", 

183} 

184 

185ANCHOR_RE = re.compile(r"^#[a-z]+$") 

186 

187NOTIFICATION_PREFIX_TEMPLATE = "notifications__{}" 

188 

189# Override python-social-auth User-Agent header 

190settings.SOCIAL_AUTH_USER_AGENT = USER_AGENT 

191 

192 

193class EmailSentView(TemplateView): 

194 r"""Class for rendering "E-mail sent" page.""" 

195 

196 template_name = "accounts/email-sent.html" 

197 

198 do_password_reset: bool 

199 do_account_remove: bool 

200 

201 def get_context_data(self, **kwargs): 

202 """Create context for rendering page.""" 

203 context = super().get_context_data(**kwargs) 

204 context["validity"] = settings.AUTH_TOKEN_VALID // 3600 

205 context["is_reset"] = False 

206 context["is_remove"] = False 

207 if self.do_password_reset: 

208 context["title"] = gettext("Password reset") 

209 context["is_reset"] = True 

210 elif self.do_account_remove: 

211 context["title"] = gettext("Remove account") 

212 context["is_remove"] = True 

213 else: 

214 context["title"] = gettext("User registration") 

215 

216 return context 

217 

218 def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

219 if not request.session.get("registration-email-sent"): 

220 return redirect("home") 

221 

222 self.do_password_reset = request.session["password_reset"] 

223 self.do_account_remove = request.session["account_remove"] 

224 

225 # Remove session for not authenticated user here. 

226 # It is no longer needed and will just cause problems 

227 # with multiple registrations from single browser. 

228 if not request.user.is_authenticated: 

229 request.session.flush() 

230 else: 

231 request.session.pop("registration-email-sent") 

232 

233 return super().get(request, *args, **kwargs) 

234 

235 

236def mail_admins_contact( 

237 request: AuthenticatedHttpRequest, 

238 *, 

239 subject: str, 

240 message: str, 

241 context: dict[str, Any], 

242 name: str, 

243 email: str, 

244 to: list[str], 

245) -> None: 

246 """Send a message to the admins, as defined by the ADMINS setting.""" 

247 LOGGER.info("contact form from %s", email) 

248 subject = f"{settings.EMAIL_SUBJECT_PREFIX}{subject}" 

249 body = MESSAGE_TEMPLATE.format( 

250 message=message % context, 

251 address=get_ip_address(request), 

252 agent=get_user_agent(request), 

253 username=request.user.username, 

254 ) 

255 

256 if not to and settings.ADMINS: 

257 to = [a[1] for a in settings.ADMINS] 

258 elif not settings.ADMINS: 

259 messages.error(request, gettext("Could not send message to administrator.")) 

260 LOGGER.error("ADMINS not configured, cannot send message") 

261 return 

262 

263 if settings.ZAMMAD_URL and len(to) == 1 and to[0].endswith("@weblate.org"): 

264 try: 

265 submit_zammad_ticket( 

266 title=subject, 

267 body=body, 

268 name=name, 

269 email=email, 

270 ) 

271 except ZammadError as error: 

272 messages.error(request, str(error)) 

273 except OSError: 

274 report_error("Could not create ticket") 

275 messages.error( 

276 request, gettext("Could not open a ticket, please try again later.") 

277 ) 

278 else: 

279 messages.success( 

280 request, 

281 gettext("Your request has been sent, you will shortly hear from us."), 

282 ) 

283 

284 else: 

285 headers = get_email_headers("contact") 

286 sender = format_address(name, email) 

287 

288 if settings.CONTACT_FORM == "reply-to": 

289 headers["Reply-To"] = sender 

290 from_email = settings.DEFAULT_FROM_EMAIL 

291 else: 

292 from_email = sender 

293 

294 mail = EmailMessage( 

295 subject=subject, body=body, to=to, from_email=from_email, headers=headers 

296 ) 

297 

298 mail.send(fail_silently=False) 

299 

300 messages.success( 

301 request, 

302 gettext("Your request has been sent, you will shortly hear from us."), 

303 ) 

304 

305 

306def redirect_profile(page: str | None = None): 

307 url = reverse("profile") 

308 if page and ANCHOR_RE.match(page): 

309 url = f"{url}{page}" 

310 return HttpResponseRedirect(url) 

311 

312 

313def get_notification_forms(request: AuthenticatedHttpRequest): 

314 user = request.user 

315 subscriptions: dict[tuple[NotificationScope, int, int], dict[str, int]] = ( 

316 defaultdict(dict) 

317 ) 

318 initials: dict[tuple[NotificationScope, int, int], dict[str, Any]] = {} 

319 key: tuple[NotificationScope, int, int] 

320 

321 # Ensure watched, admin and all scopes are visible 

322 for needed in ( 

323 NotificationScope.SCOPE_WATCHED, 

324 NotificationScope.SCOPE_ADMIN, 

325 NotificationScope.SCOPE_ALL, 

326 ): 

327 key = (needed, -1, -1) 

328 subscriptions[key] = {} 

329 initials[key] = {"scope": needed, "project": None, "component": None} 

330 active = (NotificationScope.SCOPE_WATCHED, -1, -1) 

331 

332 # Include additional scopes from request 

333 if "notify_project" in request.GET: 

334 try: 

335 project = user.allowed_projects.get(pk=request.GET["notify_project"]) 

336 active = key = (NotificationScope.SCOPE_PROJECT, project.pk, -1) 

337 subscriptions[key] = {} 

338 initials[key] = { 

339 "scope": NotificationScope.SCOPE_PROJECT, 

340 "project": project, 

341 "component": None, 

342 } 

343 except (ObjectDoesNotExist, ValueError): 

344 pass 

345 if "notify_component" in request.GET: 

346 try: 

347 component = Component.objects.filter_access(user).get( 

348 pk=request.GET["notify_component"], 

349 ) 

350 active = key = (NotificationScope.SCOPE_COMPONENT, -1, component.pk) 

351 subscriptions[key] = {} 

352 initials[key] = { 

353 "scope": NotificationScope.SCOPE_COMPONENT, 

354 "component": component, 

355 } 

356 except (ObjectDoesNotExist, ValueError): 

357 pass 

358 

359 # Populate scopes from the database 

360 for subscription in user.subscription_set.select_related("project", "component"): 

361 key = ( 

362 subscription.scope, 

363 subscription.project_id or -1, 

364 subscription.component_id or -1, 

365 ) 

366 subscriptions[key][subscription.notification] = subscription.frequency 

367 initials[key] = { 

368 "scope": subscription.scope, 

369 "project": subscription.project, 

370 "component": subscription.component, 

371 } 

372 

373 # Generate forms 

374 for i, details in enumerate(sorted(subscriptions.items())): 

375 yield NotificationForm( 

376 user=user, 

377 show_default=i > 1, 

378 removable=i > 2, 

379 subscriptions=details[1], 

380 is_active=details[0] == active, 

381 initial=initials[details[0]], 

382 prefix=NOTIFICATION_PREFIX_TEMPLATE.format(i), 

383 data=request.POST if request.method == "POST" else None, 

384 ) 

385 for i in range(len(subscriptions), 200): 

386 prefix = NOTIFICATION_PREFIX_TEMPLATE.format(i) 

387 if prefix + "-scope" in request.POST or i < len(subscriptions): 

388 yield NotificationForm( 

389 user=user, 

390 show_default=i > 1, 

391 removable=i > 2, 

392 subscriptions={}, 

393 is_active=i == 0, 

394 prefix=prefix, 

395 data=request.POST, 

396 initial=initials[details[0]], 

397 ) 

398 

399 

400@never_cache 

401@login_required 

402def user_profile(request: AuthenticatedHttpRequest): 

403 user = request.user 

404 profile = user.profile 

405 profile.fixup_profile(request) 

406 

407 form_classes: list[type[ProfileBaseForm | UserForm]] = [ 

408 LanguagesForm, 

409 SubscriptionForm, 

410 UserSettingsForm, 

411 DashboardSettingsForm, 

412 ProfileForm, 

413 CommitForm, 

414 UserForm, 

415 ] 

416 forms = [form.from_request(request) for form in form_classes] 

417 forms.extend(get_notification_forms(request)) 

418 all_backends = get_auth_keys() 

419 

420 if request.method == "POST": 

421 if all(form.is_valid() for form in forms): 

422 # Save changes 

423 for form in forms: 

424 if hasattr(form, "audit"): 

425 form.audit(request) 

426 form.save() 

427 

428 messages.success(request, gettext("Your profile has been updated.")) 

429 

430 # Redirect after saving (and possibly changing language) 

431 return redirect_profile(request.POST.get("activetab")) 

432 elif not user.has_usable_password() and "email" in all_backends: 

433 messages.warning(request, render_to_string("accounts/password-warning.html")) 

434 

435 social = user.social_auth.all() 

436 social_names = [assoc.provider for assoc in social] 

437 new_backends = [ 

438 x for x in sorted(all_backends) if x == "email" or x not in social_names 

439 ] 

440 user_translation_ids = set( 

441 Change.objects.filter( 

442 user=user, timestamp__gte=timezone.now() - timedelta(days=90) 

443 ).values_list("translation", flat=True) 

444 ) 

445 license_components = ( 

446 Component.objects.filter_access(user) 

447 .filter(translation__id__in=user_translation_ids) 

448 .exclude(license="") 

449 .prefetch(alerts=False) 

450 .distinct() 

451 .order_by("license") 

452 ) 

453 

454 return render( 

455 request, 

456 "accounts/profile.html", 

457 { 

458 "languagesform": forms[0], 

459 "subscriptionform": forms[1], 

460 "usersettingsform": forms[2], 

461 "dashboardsettingsform": forms[3], 

462 "profileform": forms[4], 

463 "commitform": forms[5], 

464 "userform": forms[6], 

465 "notification_forms": forms[7:], 

466 "all_forms": forms, 

467 "user_groups": user.cached_groups, 

468 "profile": profile, 

469 "title": gettext("User profile"), 

470 "licenses": license_components, 

471 "associated": social, 

472 "new_backends": new_backends, 

473 "has_email_auth": "email" in all_backends, 

474 "auditlog": user.auditlog_set.order()[:20], 

475 "totp_keys": user.totpdevice_set.all(), 

476 "webauthn_keys": user.webauthncredential_set.all(), 

477 "recovery_keys_count": StaticToken.objects.filter( 

478 device__user=user 

479 ).count(), 

480 "bootstrap_5": True, 

481 }, 

482 ) 

483 

484 

485@login_required 

486@session_ratelimit_post("remove") 

487@never_cache 

488def user_remove(request: AuthenticatedHttpRequest): 

489 is_confirmation = "remove_confirm" in request.session 

490 if is_confirmation: 

491 if request.method == "POST": 

492 remove_user(request.user, request) 

493 rotate_token(request) 

494 auth_logout(request) 

495 messages.success(request, gettext("Your account has been removed.")) 

496 return redirect("home") 

497 confirm_form = EmptyConfirmForm(request) 

498 

499 elif request.method == "POST": 

500 confirm_form = PasswordConfirmForm(request, request.POST) 

501 if confirm_form.is_valid(): 

502 store_userid(request, remove=True) 

503 request.GET = {"email": request.user.email} # type: ignore[assignment] 

504 AuditLog.objects.create( 

505 request.user, request, "removal-request", **request.GET 

506 ) 

507 return social_complete(request, "email") 

508 else: 

509 confirm_form = PasswordConfirmForm(request) 

510 

511 return render( 

512 request, 

513 "accounts/removal.html", 

514 {"confirm_form": confirm_form, "is_confirmation": is_confirmation}, 

515 ) 

516 

517 

518@session_ratelimit_post("confirm") 

519@never_cache 

520def confirm(request: AuthenticatedHttpRequest): 

521 details = request.session.get("reauthenticate") 

522 if not details: 

523 return redirect("home") 

524 

525 if request.method == "POST": 

526 confirm_form = PasswordConfirmForm( 

527 request, request.POST, user=User.objects.get(pk=details["user_pk"]) 

528 ) 

529 if confirm_form.is_valid(): 

530 request.session.pop("reauthenticate") 

531 request.session["reauthenticate_done"] = True 

532 return redirect("social:complete", backend=details["backend"]) 

533 else: 

534 confirm_form = PasswordConfirmForm(request) 

535 

536 context = {"confirm_form": confirm_form} 

537 context.update(details) 

538 

539 return render(request, "accounts/confirm.html", context) 

540 

541 

542def get_initial_contact(request: AuthenticatedHttpRequest): 

543 """Fill in initial contact form fields from request.""" 

544 initial = {} 

545 if request.user.is_authenticated: 

546 initial["name"] = request.user.full_name 

547 initial["email"] = request.user.email 

548 return initial 

549 

550 

551@never_cache 

552def contact(request: AuthenticatedHttpRequest): 

553 if request.method == "POST": 

554 form = ContactForm( 

555 request=request, 

556 hide_captcha=request.user.is_authenticated, 

557 data=request.POST, 

558 ) 

559 if not check_rate_limit("message", request): 

560 messages.error( 

561 request, gettext("Too many messages sent, please try again later.") 

562 ) 

563 elif form.is_valid(): 

564 mail_admins_contact( 

565 request, 

566 subject=form.cleaned_data["subject"], 

567 message=CONTACT_TEMPLATE, 

568 context=form.cleaned_data, 

569 name=form.cleaned_data["name"], 

570 email=form.cleaned_data["email"], 

571 to=settings.ADMINS_CONTACT, 

572 ) 

573 return redirect("home") 

574 else: 

575 initial = get_initial_contact(request) 

576 if request.GET.get("t") in CONTACT_SUBJECTS: 

577 initial["subject"] = CONTACT_SUBJECTS[request.GET["t"]] 

578 form = ContactForm( 

579 request=request, hide_captcha=request.user.is_authenticated, initial=initial 

580 ) 

581 

582 return render( 

583 request, 

584 "accounts/contact.html", 

585 {"form": form, "title": gettext("Contact")}, 

586 ) 

587 

588 

589@login_required 

590@session_ratelimit_post("hosting") 

591@never_cache 

592def hosting(request: AuthenticatedHttpRequest): 

593 """Form for hosting request.""" 

594 if not settings.OFFER_HOSTING: 

595 return redirect("home") 

596 

597 from weblate.billing.models import Billing 

598 

599 billings = ( 

600 Billing.objects.for_user(request.user) 

601 .filter(state=Billing.STATE_TRIAL) 

602 .order_by("-payment", "expiry") 

603 ) 

604 

605 return render( 

606 request, 

607 "accounts/hosting.html", 

608 { 

609 "title": gettext("Hosting"), 

610 "billings": billings, 

611 }, 

612 ) 

613 

614 

615@login_required 

616@session_ratelimit_post("trial") 

617@never_cache 

618def trial(request: AuthenticatedHttpRequest): 

619 """Form for hosting request.""" 

620 if not settings.OFFER_HOSTING: 

621 return redirect("home") 

622 

623 plan = request.POST.get("plan", "640k") 

624 

625 # Avoid frequent requests for a trial for same user 

626 if plan != "libre" and request.user.auditlog_set.filter(activity="trial").exists(): 

627 messages.error( 

628 request, 

629 gettext( 

630 "Seems you've already requested a trial period recently. " 

631 "Please contact us with your inquiry so we can find the " 

632 "best solution for you." 

633 ), 

634 ) 

635 return redirect(reverse("contact") + "?t=trial") 

636 

637 if request.method == "POST": 

638 from weblate.billing.models import Billing, BillingEvent, Plan 

639 

640 AuditLog.objects.create(request.user, request, "trial") 

641 billing = Billing.objects.create( 

642 plan=Plan.objects.get(slug=plan), 

643 state=Billing.STATE_TRIAL, 

644 expiry=timezone.now() + timedelta(days=14), 

645 ) 

646 billing.billinglog_set.create(event=BillingEvent.CREATED, user=request.user) 

647 billing.owners.add(request.user) 

648 messages.info( 

649 request, 

650 gettext( 

651 "Your trial period is now up and running; " 

652 "create your translation project and start Weblating!" 

653 ), 

654 ) 

655 return redirect(reverse("create-project") + f"?billing={billing.pk}") 

656 

657 return render(request, "accounts/trial.html", {"title": gettext("Gratis trial")}) 

658 

659 

660class UserPage(UpdateView): 

661 model = User 

662 template_name = "accounts/user.html" 

663 slug_field = "username" 

664 slug_url_kwarg = "user" 

665 context_object_name = "page_user" 

666 form_class = UserEditForm 

667 

668 group_form = None 

669 request: AuthenticatedHttpRequest 

670 

671 def post(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

672 if not request.user.has_perm("user.edit"): 

673 raise PermissionDenied 

674 user = self.object = self.get_object() 

675 if "add_group" in request.POST: 

676 self.group_form = GroupAddForm(request.POST) 

677 if self.group_form.is_valid(): 

678 user.add_team(request, self.group_form.cleaned_data["add_group"]) 

679 return HttpResponseRedirect(self.get_success_url() + "#groups") 

680 if "remove_group" in request.POST: 

681 form = GroupRemoveForm(request.POST) 

682 if form.is_valid(): 

683 user.remove_team(request, form.cleaned_data["remove_group"]) 

684 return HttpResponseRedirect(self.get_success_url() + "#groups") 

685 if "remove_user" in request.POST: 

686 remove_user(user, request, skip_notify=True) 

687 return HttpResponseRedirect(self.get_success_url() + "#groups") 

688 

689 if "remove_2fa" in request.POST: 

690 for device in user.profile.second_factors: 

691 key_name = get_key_name(device) 

692 device.delete() 

693 AuditLog.objects.create(user, None, "twofactor-remove", device=key_name) 

694 return HttpResponseRedirect(self.get_success_url() + "#edit") 

695 

696 if "disable_password" in request.POST: 

697 lock_user(user, "admin-locked") 

698 return HttpResponseRedirect(self.get_success_url() + "#edit") 

699 

700 return super().post(request, *args, **kwargs) 

701 

702 def get_queryset(self): 

703 return super().get_queryset().select_related("profile") 

704 

705 def get_context_data(self, **kwargs): 

706 """Create context for rendering page.""" 

707 context = super().get_context_data(**kwargs) 

708 user = self.object 

709 request = self.request 

710 

711 allowed_projects = request.user.allowed_projects 

712 

713 # Filter all user activity 

714 all_changes = Change.objects.last_changes(request.user).filter(user=user) 

715 

716 # Filter where project is active 

717 user_translation_ids = set( 

718 all_changes.content() 

719 .filter(timestamp__gte=timezone.now() - timedelta(days=90)) 

720 .values_list("translation", flat=True) 

721 ) 

722 user_translations = ( 

723 Translation.objects.prefetch() 

724 .filter( 

725 id__in=list(user_translation_ids)[:10], 

726 component__project__in=allowed_projects, 

727 ) 

728 .order() 

729 ) 

730 

731 context["page_profile"] = user.profile 

732 # Last user activity 

733 context["last_changes"] = all_changes.recent() 

734 context["last_changes_url"] = urlencode({"user": user.username}) 

735 context["page_user_translations"] = translation_prefetch_tasks( 

736 prefetch_stats(user_translations) 

737 ) 

738 owned = (user.owned_projects & allowed_projects.distinct()).order()[:11] 

739 context["page_owned_projects_more"] = len(owned) == 11 

740 context["page_owned_projects"] = prefetch_project_flags( 

741 prefetch_stats(owned[:10]) 

742 ) 

743 watched = (user.watched_projects & allowed_projects).order()[:11] 

744 context["page_watched_projects_more"] = len(watched) == 11 

745 context["page_watched_projects"] = prefetch_project_flags( 

746 prefetch_stats(watched[:10]) 

747 ) 

748 context["user_languages"] = user.profile.all_languages[:7] 

749 context["group_form"] = self.group_form or GroupAddForm() 

750 context["page_user_groups"] = ( 

751 user.groups.annotate(Count("user")) 

752 .prefetch_related("defining_project") 

753 .order() 

754 ) 

755 context["bootstrap_5"] = True 

756 return context 

757 

758 

759def user_contributions(request: AuthenticatedHttpRequest, user: str): 

760 page_user = get_object_or_404(User, username=user) 

761 user_translation_ids = set( 

762 Change.objects.content() 

763 .filter(user=page_user) 

764 .values_list("translation", flat=True) 

765 ) 

766 user_translations = ( 

767 Translation.objects.filter_access(request.user) 

768 .prefetch() 

769 .filter( 

770 id__in=user_translation_ids, 

771 ) 

772 .order() 

773 ) 

774 return render( 

775 request, 

776 "accounts/user_contributions.html", 

777 { 

778 "page_user": page_user, 

779 "page_profile": page_user.profile, 

780 "page_user_translations": translation_prefetch_tasks( 

781 prefetch_stats(get_paginator(request, user_translations)) 

782 ), 

783 }, 

784 ) 

785 

786 

787def user_avatar(request: AuthenticatedHttpRequest, user: str, size: int): 

788 """User avatar view.""" 

789 allowed_sizes = ( 

790 # Used in top navigation 

791 24, 

792 # In text avatars 

793 32, 

794 # 80 pixels used when linked with weblate.org 

795 80, 

796 # Public profile 

797 128, 

798 ) 

799 if size not in allowed_sizes: 

800 msg = f"Not supported size: {size}" 

801 raise Http404(msg) 

802 

803 avatar_user = get_object_or_404(User, username=user) 

804 email = avatar_user.email 

805 

806 # Deleted users 

807 if email == f"noreply+{avatar_user.pk}@weblate.org": 

808 return redirect( 

809 os.path.join(settings.STATIC_URL, "state/ghost.svg"), permanent=True 

810 ) 

811 # Bot and anonymous accounts 

812 if not email or (email.startswith("noreply") and email.endswith("@weblate.org")): 

813 return redirect(get_fallback_avatar_url(int(size)), permanent=True) 

814 # Project API tokens 

815 if email.endswith("@bots.noreply.weblate.org"): 

816 return redirect(get_fallback_avatar_url(int(size), "api"), permanent=True) 

817 

818 response = HttpResponse( 

819 content_type="image/png", content=get_avatar_image(avatar_user, size) 

820 ) 

821 

822 patch_response_headers(response, 3600 * 24 * 7) 

823 

824 return response 

825 

826 

827def redirect_single(request: AuthenticatedHttpRequest, backend: str): 

828 """Redirect user to single authentication backend.""" 

829 return render( 

830 request, 

831 "accounts/redirect.html", 

832 {"backend": backend, "next": request.GET.get("next")}, 

833 ) 

834 

835 

836class BaseLoginView(LoginView): 

837 def form_invalid(self, form): 

838 rotate_token(self.request) 

839 return super().form_invalid(form) 

840 

841 def form_valid(self, form): 

842 """Security check complete. Log the user in.""" 

843 user = form.get_user() 

844 if user.profile.has_2fa: 

845 # Store session indication for second factor 

846 self.request.session[SESSION_SECOND_FACTOR_USER] = (user.id, user.backend) 

847 # Redirect to second factor login 

848 redirect_to = self.request.POST.get( 

849 self.redirect_field_name, self.request.GET.get(self.redirect_field_name) 

850 ) 

851 login_params: dict[str, str] = {} 

852 if redirect_to: 

853 login_params[self.redirect_field_name] = redirect_to 

854 login_url = reverse( 

855 "2fa-login", kwargs={"backend": user.profile.get_second_factor_type()} 

856 ) 

857 return HttpResponseRedirect(f"{login_url}?{urlencode(login_params)}") 

858 auth_login(self.request, user) 

859 return HttpResponseRedirect(self.get_success_url()) 

860 

861 

862class WeblateLoginView(BaseLoginView): 

863 """Login handler, just a wrapper around standard Django login.""" 

864 

865 form_class = LoginForm # type: ignore[assignment] 

866 template_name = "accounts/login.html" 

867 redirect_authenticated_user = True 

868 

869 @cached_property 

870 def has_email_auth(self) -> bool: 

871 return "email" in get_auth_keys() 

872 

873 @cached_property 

874 def show_login_form(self) -> bool: 

875 return self.has_email_auth or any( 

876 not isinstance(backend, (BaseAuth, WeblateUserBackend)) 

877 for backend in get_backends() 

878 ) 

879 

880 def get_context_data(self, **kwargs): 

881 context = super().get_context_data(**kwargs) 

882 auth_backends = get_auth_keys() 

883 context["login_backends"] = [x for x in sorted(auth_backends) if x != "email"] 

884 context["can_reset"] = self.has_email_auth 

885 # Show login form for e-mail login or any third-party Django auth backend such as LDAP 

886 context["show_login_form"] = self.show_login_form 

887 context["title"] = gettext("Sign in") 

888 return context 

889 

890 @method_decorator(never_cache) 

891 def dispatch(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

892 # Redirect signed in users to profile 

893 if request.user.is_authenticated: 

894 return redirect_profile() 

895 

896 # Redirect if there is only one backend 

897 auth_backends = get_auth_keys() 

898 if len(auth_backends) == 1 and "email" not in auth_backends: 

899 return redirect_single(request, auth_backends.pop()) 

900 

901 if request.method == "POST" and not self.show_login_form: 

902 return redirect("login") 

903 

904 return super().dispatch(request, *args, **kwargs) 

905 

906 

907class WeblateLogoutView(TemplateView): 

908 """ 

909 Logout handler, just a reimplementation of standard Django logout. 

910 

911 - no redirect support 

912 - login_required decorator 

913 """ 

914 

915 http_method_names = ["post", "options"] # noqa: RUF012 

916 template_name = "registration/logged_out.html" 

917 request: AuthenticatedHttpRequest 

918 

919 @method_decorator(require_POST) 

920 @method_decorator(login_required) 

921 @method_decorator(never_cache) 

922 def post(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

923 """Logout may be done via POST.""" 

924 auth_logout(request) 

925 request.user = get_anonymous() 

926 return super().get(request, *args, **kwargs) 

927 

928 def get_context_data(self, **kwargs): 

929 context = super().get_context_data(**kwargs) 

930 context["title"] = gettext("Signed out") 

931 return context 

932 

933 

934def fake_email_sent(request: AuthenticatedHttpRequest, reset: bool = False): 

935 """Fake redirect to e-mail sent page.""" 

936 request.session["registration-email-sent"] = True 

937 request.session["password_reset"] = reset 

938 request.session["account_remove"] = False 

939 return redirect("email-sent") 

940 

941 

942@never_cache 

943def register(request: AuthenticatedHttpRequest): 

944 """Registration form.""" 

945 # Fetch invitation 

946 invitation: Invitation | None = None 

947 initial = {} 

948 if invitation_pk := request.session.get("invitation_link"): 

949 try: 

950 invitation = Invitation.objects.get(pk=invitation_pk) 

951 except Invitation.DoesNotExist: 

952 del request.session["invitation_link"] 

953 else: 

954 initial["email"] = invitation.email 

955 initial["username"] = invitation.username 

956 initial["fullname"] = invitation.full_name 

957 

958 # Allow registration at all? 

959 registration_open = settings.REGISTRATION_OPEN or bool(invitation) 

960 

961 # Hide captcha for invitations 

962 hide_captcha = invitation is not None 

963 

964 # Get list of allowed backends 

965 backends = get_auth_keys() 

966 if settings.REGISTRATION_ALLOW_BACKENDS and not invitation: 

967 backends &= set(settings.REGISTRATION_ALLOW_BACKENDS) 

968 elif not registration_open: 

969 backends = set() 

970 

971 if request.method == "POST" and "email" in backends: 

972 form = RegistrationForm( 

973 request=request, data=request.POST, hide_captcha=hide_captcha 

974 ) 

975 if form.is_valid(): 

976 if form.cleaned_data["email_user"]: 

977 AuditLog.objects.create( 

978 form.cleaned_data["email_user"], request, "connect" 

979 ) 

980 return fake_email_sent(request) 

981 store_userid(request) 

982 return social_complete(request, "email") 

983 else: 

984 form = RegistrationForm( 

985 request=request, initial=initial, hide_captcha=hide_captcha 

986 ) 

987 

988 # Redirect if there is only one backend 

989 if len(backends) == 1 and "email" not in backends and not invitation: 

990 return redirect_single(request, backends.pop()) 

991 

992 return render( 

993 request, 

994 "accounts/register.html", 

995 { 

996 "registration_email": "email" in backends, 

997 "registration_backends": backends - {"email"}, 

998 "title": gettext("User registration"), 

999 "form": form, 

1000 "invitation": invitation, 

1001 }, 

1002 ) 

1003 

1004 

1005@login_required 

1006@never_cache 

1007def email_login(request: AuthenticatedHttpRequest): 

1008 """Connect e-mail.""" 

1009 if request.method == "POST": 

1010 form = EmailForm(request=request, data=request.POST) 

1011 if form.is_valid(): 

1012 email_user = form.cleaned_data["email_user"] 

1013 if email_user and email_user != request.user: 

1014 AuditLog.objects.create( 

1015 form.cleaned_data["email_user"], request, "connect" 

1016 ) 

1017 return fake_email_sent(request) 

1018 store_userid(request) 

1019 return social_complete(request, "email") 

1020 else: 

1021 form = EmailForm(request=request) 

1022 

1023 return render( 

1024 request, 

1025 "accounts/email.html", 

1026 {"title": gettext("Register e-mail"), "form": form}, 

1027 ) 

1028 

1029 

1030@login_required 

1031@session_ratelimit_post("password") 

1032@never_cache 

1033def password(request: AuthenticatedHttpRequest): 

1034 """Password change / set form.""" 

1035 do_change = True 

1036 change_form = None 

1037 usable = request.user.has_usable_password() 

1038 

1039 if "email" not in get_auth_keys() and not usable: 

1040 messages.error( 

1041 request, 

1042 gettext("Cannot reset password, e-mail authentication is turned off."), 

1043 ) 

1044 return redirect("profile") 

1045 

1046 if usable: 

1047 if request.method == "POST": 

1048 change_form = PasswordConfirmForm(request, request.POST) 

1049 do_change = change_form.is_valid() 

1050 else: 

1051 change_form = PasswordConfirmForm(request) 

1052 do_change = False 

1053 

1054 if request.method == "POST": 

1055 form = SetPasswordForm(request.user, request.POST) 

1056 if form.is_valid() and do_change: 

1057 # Clear flag forcing user to set password 

1058 redirect_page = "#account" 

1059 if "show_set_password" in request.session: 

1060 del request.session["show_set_password"] 

1061 redirect_page = "" 

1062 

1063 # Change the password 

1064 form.save(request) 

1065 

1066 return redirect_profile(redirect_page) 

1067 else: 

1068 form = SetPasswordForm(request.user) 

1069 

1070 return render( 

1071 request, 

1072 "accounts/password.html", 

1073 {"title": gettext("Change password"), "change_form": change_form, "form": form}, 

1074 ) 

1075 

1076 

1077def reset_password_set(request: AuthenticatedHttpRequest): 

1078 """Perform actual password reset.""" 

1079 user = User.objects.get(pk=request.session["perform_reset"]) 

1080 if user.has_usable_password(): 

1081 request.session.flush() 

1082 request.session.set_expiry(None) 

1083 messages.error(request, gettext("Password reset has been already completed.")) 

1084 return redirect("login") 

1085 if request.method == "POST": 

1086 form = SetPasswordForm(user, request.POST) 

1087 if form.is_valid(): 

1088 request.session.set_expiry(None) 

1089 form.save(request, delete_session=True) 

1090 return redirect("login") 

1091 else: 

1092 form = SetPasswordForm(user) 

1093 return render( 

1094 request, 

1095 "accounts/reset.html", 

1096 { 

1097 "title": gettext("Password reset"), 

1098 "form": form, 

1099 "second_stage": True, 

1100 }, 

1101 ) 

1102 

1103 

1104def get_registration_hint(email: str) -> str | None: 

1105 domain = email.rsplit("@", 1)[-1] 

1106 return settings.REGISTRATION_HINTS.get(domain) 

1107 

1108 

1109@never_cache 

1110def reset_password(request: AuthenticatedHttpRequest): 

1111 """Password reset handling.""" 

1112 if request.user.is_authenticated: 

1113 return redirect_profile() 

1114 if "email" not in get_auth_keys(): 

1115 messages.error( 

1116 request, 

1117 gettext("Cannot reset password, e-mail authentication is turned off."), 

1118 ) 

1119 return redirect("login") 

1120 

1121 # We're already in the reset phase 

1122 if "perform_reset" in request.session: 

1123 return reset_password_set(request) 

1124 if request.method == "POST": 

1125 form = ResetForm(request=request, data=request.POST) 

1126 if form.is_valid(): 

1127 if form.cleaned_data["email_user"]: 

1128 audit = AuditLog.objects.create( 

1129 form.cleaned_data["email_user"], request, "reset-request" 

1130 ) 

1131 if not audit.check_rate_limit(request): 

1132 store_userid(request, reset=True) 

1133 return social_complete(request, "email") 

1134 else: 

1135 email = form.cleaned_data["email"] 

1136 send_notification_email( 

1137 None, 

1138 [email], 

1139 "reset-nonexisting", 

1140 context={ 

1141 "address": get_ip_address(request), 

1142 "user_agent": get_user_agent(request), 

1143 "registration_hint": get_registration_hint(email), 

1144 }, 

1145 ) 

1146 return fake_email_sent(request, True) 

1147 else: 

1148 form = ResetForm(request=request) 

1149 

1150 return render( 

1151 request, 

1152 "accounts/reset.html", 

1153 { 

1154 "title": gettext("Password reset"), 

1155 "form": form, 

1156 "second_stage": False, 

1157 }, 

1158 ) 

1159 

1160 

1161@require_POST 

1162@login_required 

1163@session_ratelimit_post("reset_api") 

1164def reset_api_key(request: AuthenticatedHttpRequest): 

1165 """Reset user API key.""" 

1166 # Need to delete old token as key is primary key 

1167 with transaction.atomic(): 

1168 Token.objects.filter(user=request.user).delete() 

1169 Token.objects.create(user=request.user, key=get_token("wlu")) 

1170 

1171 return redirect_profile("#api") 

1172 

1173 

1174@require_POST 

1175@login_required 

1176@session_ratelimit_post("userdata") 

1177def userdata(request: AuthenticatedHttpRequest): 

1178 response = JsonResponse(request.user.profile.dump_data()) 

1179 response["Content-Disposition"] = 'attachment; filename="weblate.json"' 

1180 return response 

1181 

1182 

1183@require_POST 

1184@login_required 

1185def watch(request: AuthenticatedHttpRequest, path): 

1186 user = request.user 

1187 redirect_obj = obj = parse_path(request, path, (Component, Project)) 

1188 if isinstance(obj, Component): 

1189 project = obj.project 

1190 

1191 # Mute project level subscriptions 

1192 mute_real( 

1193 user, scope=NotificationScope.SCOPE_PROJECT, component=None, project=project 

1194 ) 

1195 # Manually enable component level subscriptions 

1196 for default_subscription in user.subscription_set.filter( 

1197 scope=NotificationScope.SCOPE_WATCHED 

1198 ): 

1199 subscription, created = user.subscription_set.get_or_create( 

1200 notification=default_subscription.notification, 

1201 scope=NotificationScope.SCOPE_COMPONENT, 

1202 component=obj, 

1203 project=None, 

1204 defaults={"frequency": default_subscription.frequency}, 

1205 ) 

1206 if not created and subscription.frequency != default_subscription.frequency: 

1207 subscription.frequency = default_subscription.frequency 

1208 subscription.save(update_fields=["frequency"]) 

1209 

1210 # Watch project 

1211 obj = project 

1212 user.profile.watched.add(obj) 

1213 return redirect_next(request.GET.get("next"), redirect_obj) 

1214 

1215 

1216@require_POST 

1217@login_required 

1218def unwatch(request: AuthenticatedHttpRequest, path): 

1219 obj = parse_path(request, path, (Project,)) 

1220 request.user.profile.watched.remove(obj) 

1221 request.user.subscription_set.filter( 

1222 Q(project=obj) | Q(component__project=obj) 

1223 ).delete() 

1224 return redirect_next(request.GET.get("next"), obj) 

1225 

1226 

1227def mute_real(user: User, **kwargs) -> None: 

1228 for notification_cls in NOTIFICATIONS: 

1229 if notification_cls.ignore_watched: 

1230 continue 

1231 try: 

1232 subscription = user.subscription_set.get_or_create( 

1233 notification=notification_cls.get_name(), 

1234 defaults={"frequency": NotificationFrequency.FREQ_NONE}, 

1235 **kwargs, 

1236 )[0] 

1237 except Subscription.MultipleObjectsReturned: 

1238 subscriptions = user.subscription_set.filter( 

1239 notification=notification_cls.get_name(), **kwargs 

1240 ) 

1241 # Remove extra subscriptions 

1242 for subscription in subscriptions[1:]: 

1243 subscription.delete() 

1244 subscription = subscriptions[0] 

1245 if subscription.frequency != NotificationFrequency.FREQ_NONE: 

1246 subscription.frequency = NotificationFrequency.FREQ_NONE 

1247 subscription.save(update_fields=["frequency"]) 

1248 

1249 

1250@require_POST 

1251@login_required 

1252def mute(request: AuthenticatedHttpRequest, path): 

1253 obj = parse_path(request, path, (Component, Project)) 

1254 if isinstance(obj, Component): 

1255 mute_real( 

1256 request.user, 

1257 scope=NotificationScope.SCOPE_COMPONENT, 

1258 component=obj, 

1259 project=None, 

1260 ) 

1261 return redirect( 

1262 "{}?notify_component={}#notifications".format(reverse("profile"), obj.pk) 

1263 ) 

1264 mute_real( 

1265 request.user, scope=NotificationScope.SCOPE_PROJECT, component=None, project=obj 

1266 ) 

1267 return redirect( 

1268 "{}?notify_project={}#notifications".format(reverse("profile"), obj.pk) 

1269 ) 

1270 

1271 

1272class SuggestionView(ListView): 

1273 paginate_by = 25 

1274 model = Suggestion 

1275 

1276 def get_queryset(self): 

1277 if self.kwargs["user"] == "-": 

1278 user = None 

1279 else: 

1280 user = get_object_or_404(User, username=self.kwargs["user"]) 

1281 return ( 

1282 Suggestion.objects.filter_access(self.request.user) 

1283 .filter(user=user) 

1284 .order() 

1285 ) 

1286 

1287 def get_context_data(self, *, object_list=None, **kwargs): 

1288 result = super().get_context_data(object_list=object_list, **kwargs) 

1289 if self.kwargs["user"] == "-": 

1290 user = User.objects.get(username=settings.ANONYMOUS_USER_NAME) 

1291 else: 

1292 user = get_object_or_404(User, username=self.kwargs["user"]) 

1293 result["page_user"] = user 

1294 result["page_profile"] = user.profile 

1295 return result 

1296 

1297 

1298def store_userid( 

1299 request: AuthenticatedHttpRequest, *, reset: bool = False, remove: bool = False 

1300) -> None: 

1301 """Store user ID in the session.""" 

1302 request.session["social_auth_user"] = request.user.pk 

1303 request.session["password_reset"] = reset 

1304 request.session["account_remove"] = remove 

1305 

1306 

1307@require_POST 

1308@login_required 

1309def social_disconnect( 

1310 request: AuthenticatedHttpRequest, backend: str, association_id: str | None = None 

1311): 

1312 """ 

1313 Disconnect social authentication. 

1314 

1315 Wrapper around social_django.views.disconnect: 

1316 

1317 - Requires POST (to avoid CSRF on auth) 

1318 - Blocks disconnecting last entry 

1319 """ 

1320 # Block removal of last social auth 

1321 if request.user.social_auth.count() <= 1: 

1322 messages.error(request, gettext("Could not remove user identity")) 

1323 return redirect_profile("#account") 

1324 

1325 # Block removal of last verified email 

1326 verified = VerifiedEmail.objects.filter(social__user=request.user).exclude( 

1327 social__provider=backend, social_id=association_id 

1328 ) 

1329 if not verified.exists(): 

1330 messages.error( 

1331 request, 

1332 gettext("Add another identity by confirming your e-mail address first."), 

1333 ) 

1334 return redirect_profile("#account") 

1335 

1336 return disconnect(request, backend, association_id) 

1337 

1338 

1339@never_cache 

1340@require_POST 

1341def social_auth(request: AuthenticatedHttpRequest, backend: str): 

1342 """ 

1343 Social authentication endpoint. 

1344 

1345 Wrapper around social_django.views.auth: 

1346 

1347 - Incorporates modified social_djang.utils.psa 

1348 - Requires POST (to avoid CSRF on auth) 

1349 - Stores current user in session (to avoid CSRF upon completion) 

1350 - Stores session ID in the request URL if needed 

1351 """ 

1352 # Fill in idp in case it is not provided 

1353 if backend == "saml" and "idp" not in request.GET: 

1354 request.GET = request.GET.copy() # type: ignore[assignment] 

1355 request.GET["idp"] = "weblate" # type: ignore[misc] 

1356 store_userid(request) 

1357 uri = reverse("social:complete", args=(backend,)) 

1358 request.social_strategy = load_strategy(request) 

1359 try: 

1360 request.backend = load_backend(request.social_strategy, backend, uri) 

1361 except MissingBackend: 

1362 msg = "Backend not found" 

1363 raise Http404(msg) from None 

1364 

1365 try: 

1366 return do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME) 

1367 except AuthException as error: 

1368 report_error("Could not authenticate") 

1369 messages.error(request, gettext("Could not authenticate: %s") % error) 

1370 return redirect("login") 

1371 

1372 

1373def auth_fail(request: AuthenticatedHttpRequest, message: str): 

1374 messages.error(request, message) 

1375 return redirect(reverse("login")) 

1376 

1377 

1378def registration_fail(request: AuthenticatedHttpRequest, message: str): 

1379 messages.error(request, gettext("Could not complete registration.") + " " + message) 

1380 messages.info( 

1381 request, 

1382 gettext("Please check if you have already registered an account.") 

1383 + " " 

1384 + gettext( 

1385 "You can also request a new password, if you have lost your credentials." 

1386 ), 

1387 ) 

1388 

1389 return redirect(reverse("login")) 

1390 

1391 

1392def auth_redirect_token(request: AuthenticatedHttpRequest): 

1393 return auth_fail( 

1394 request, 

1395 gettext( 

1396 "Try registering again to verify your identity, " 

1397 "the confirmation link probably expired." 

1398 ), 

1399 ) 

1400 

1401 

1402def auth_redirect_state(request: AuthenticatedHttpRequest): 

1403 return auth_fail( 

1404 request, gettext("Could not authenticate due to invalid session state.") 

1405 ) 

1406 

1407 

1408def handle_missing_parameter( 

1409 request: AuthenticatedHttpRequest, backend: str, error: AuthMissingParameter 

1410): 

1411 if backend != "email" and error.parameter == "email": 

1412 messages = [ 

1413 gettext("Got no e-mail address from third party authentication service.") 

1414 ] 

1415 if "email" in get_auth_keys(): 

1416 # Show only if e-mail authentication is turned on 

1417 messages.append(gettext("Please register using e-mail instead.")) 

1418 return auth_fail(request, " ".join(messages)) 

1419 if error.parameter in {"email", "user", "expires"}: 

1420 return auth_redirect_token(request) 

1421 if error.parameter in {"state", "code", "RelayState"}: 

1422 return auth_redirect_state(request) 

1423 if error.parameter == "disabled": 

1424 return auth_fail(request, gettext("New registrations are turned off.")) 

1425 return None 

1426 

1427 

1428@csrf_exempt 

1429@never_cache 

1430def social_complete(request: AuthenticatedHttpRequest, backend: str): 

1431 """ 

1432 Social authentication completion endpoint. 

1433 

1434 Wrapper around social_django.views.complete: 

1435 

1436 - Handles backend errors gracefully 

1437 - Intermediate page (autosubmitted by JavaScript) to avoid 

1438 confirmations by bots 

1439 """ 

1440 if ( 

1441 "partial_token" in request.GET 

1442 and "verification_code" in request.GET 

1443 and "confirm" not in request.GET 

1444 ): 

1445 return render( 

1446 request, 

1447 "accounts/token.html", 

1448 { 

1449 "partial_token": request.GET["partial_token"], 

1450 "verification_code": request.GET["verification_code"], 

1451 "backend": backend, 

1452 }, 

1453 ) 

1454 try: 

1455 response = complete(request, backend) 

1456 except InvalidEmail: 

1457 report_error("Could not register") 

1458 return auth_redirect_token(request) 

1459 except AuthMissingParameter as error: 

1460 report_error("Could not register") 

1461 result = handle_missing_parameter(request, backend, error) 

1462 if result: 

1463 return result 

1464 raise 

1465 except (AuthStateMissing, AuthStateForbidden): 

1466 report_error("Could not register") 

1467 return auth_redirect_state(request) 

1468 except AuthFailed: 

1469 report_error("Could not register") 

1470 return auth_fail( 

1471 request, 

1472 gettext( 

1473 "Could not authenticate, probably due to an expired token " 

1474 "or connection error." 

1475 ), 

1476 ) 

1477 except AuthCanceled: 

1478 report_error("Could not register") 

1479 return auth_fail(request, gettext("Authentication cancelled.")) 

1480 except AuthForbidden: 

1481 report_error("Could not register") 

1482 return auth_fail(request, gettext("The server does not allow authentication.")) 

1483 except EmailAlreadyAssociated: 

1484 return registration_fail( 

1485 request, 

1486 gettext( 

1487 "The supplied e-mail address is already in use for another account." 

1488 ), 

1489 ) 

1490 except UsernameAlreadyAssociated: 

1491 return registration_fail( 

1492 request, 

1493 gettext("The supplied username is already in use for another account."), 

1494 ) 

1495 except AuthTokenError as error: 

1496 report_error("Could not authenticate") 

1497 return registration_fail( 

1498 request, 

1499 gettext("Authentication failed: %s") % error, 

1500 ) 

1501 except AuthAlreadyAssociated: 

1502 return registration_fail( 

1503 request, 

1504 gettext( 

1505 "The supplied user identity is already in use for another account." 

1506 ), 

1507 ) 

1508 except AuthUnreachableProvider: 

1509 return registration_fail( 

1510 request, 

1511 gettext("The authentication provider could not be reached."), 

1512 ) 

1513 except ValidationError as error: 

1514 report_error("Could not register") 

1515 return registration_fail(request, str(error)) 

1516 

1517 # Finish second factor authentication 

1518 if persistent_id := request.session.pop(DEVICE_ID_SESSION_KEY, None): 

1519 device = Device.from_persistent_id(persistent_id) 

1520 otp_login(request, device) 

1521 

1522 return response 

1523 

1524 

1525@login_required 

1526@require_POST 

1527def subscribe(request: AuthenticatedHttpRequest): 

1528 if "onetime" in request.POST: 

1529 component = Component.objects.get(pk=request.POST["component"]) 

1530 request.user.check_access_component(component) 

1531 subscription = Subscription( 

1532 user=request.user, 

1533 notification=request.POST["onetime"], 

1534 scope=NotificationScope.SCOPE_COMPONENT, 

1535 frequency=NotificationFrequency.FREQ_INSTANT, 

1536 project=component.project, 

1537 component=component, 

1538 onetime=True, 

1539 ) 

1540 try: 

1541 subscription.full_clean() 

1542 subscription.save() 

1543 except ValidationError: 

1544 pass 

1545 messages.success(request, gettext("Notification settings adjusted.")) 

1546 return redirect_profile("#notifications") 

1547 

1548 

1549def unsubscribe(request: AuthenticatedHttpRequest): 

1550 if "i" in request.GET: 

1551 signer = TimestampSigner() 

1552 try: 

1553 subscription = Subscription.objects.get( 

1554 pk=int(signer.unsign(request.GET["i"], max_age=24 * 3600)) 

1555 ) 

1556 subscription.frequency = NotificationFrequency.FREQ_NONE 

1557 subscription.save(update_fields=["frequency"]) 

1558 messages.success(request, gettext("Notification settings adjusted.")) 

1559 except (BadSignature, SignatureExpired, Subscription.DoesNotExist): 

1560 messages.error( 

1561 request, 

1562 gettext( 

1563 "The notification change link is no longer valid, " 

1564 "please sign in to configure notifications." 

1565 ), 

1566 ) 

1567 

1568 return redirect_profile("#notifications") 

1569 

1570 

1571@csrf_exempt 

1572@never_cache 

1573def saml_metadata(request: AuthenticatedHttpRequest): 

1574 if "social_core.backends.saml.SAMLAuth" not in settings.AUTHENTICATION_BACKENDS: 

1575 raise Http404 

1576 

1577 # Generate metadata 

1578 complete_url = reverse("social:complete", args=("saml",)) 

1579 saml_backend = social_django.utils.load_backend( 

1580 load_strategy(request), "saml", complete_url 

1581 ) 

1582 metadata, errors = saml_backend.generate_metadata_xml() 

1583 

1584 # Handle errors 

1585 if errors: 

1586 add_breadcrumb(category="auth", message="SAML errors", errors=errors) 

1587 report_error("SAML metadata", level="error") 

1588 return HttpResponseServerError(content=", ".join(errors)) 

1589 

1590 return HttpResponse(content=metadata, content_type="text/xml") 

1591 

1592 

1593@method_decorator(login_required, name="dispatch") 

1594class UserList(ListView): 

1595 paginate_by = 50 

1596 model = User 

1597 form_class = UserSearchForm 

1598 initial_query = "" 

1599 

1600 def get_base_queryset(self): 

1601 return User.objects.filter(is_active=True, is_bot=False) 

1602 

1603 def get_queryset(self): 

1604 users = self.get_base_queryset() 

1605 form = self.form 

1606 if form.is_valid(): 

1607 search = form.cleaned_data.get("q", "") 

1608 if search: 

1609 users = users.search(search, parser=form.fields["q"].parser) 

1610 else: 

1611 users = users.order() 

1612 

1613 return users.order_by(self.sort_query) 

1614 

1615 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override] 

1616 super().setup(request, *args, **kwargs) 

1617 if "q" in request.GET: 

1618 form = self.form_class(request.GET) 

1619 else: 

1620 form = self.form_class({"q": self.initial_query}) 

1621 self.form = form 

1622 self.sort_query = "" 

1623 if form.is_valid(): 

1624 self.sort_query = form.cleaned_data.get("sort_by", "") 

1625 if not self.sort_query: 

1626 self.sort_query = "-date_joined" 

1627 

1628 def get_context_data(self, **kwargs): 

1629 """Create context for rendering page.""" 

1630 context = super().get_context_data(**kwargs) 

1631 context["form"] = self.form 

1632 context["sort_query"] = self.sort_query 

1633 context["sort_name"] = self.form.sort_choices[self.sort_query.strip("-")] 

1634 context["sort_choices"] = self.form.sort_choices 

1635 context["search_items"] = ( 

1636 ("q", self.form.cleaned_data.get("q", "").strip()), 

1637 ("sort_by", self.sort_query), 

1638 ) 

1639 context["query_string"] = urlencode(context["search_items"]) 

1640 return context 

1641 

1642 

1643@method_decorator(login_required, name="dispatch") 

1644class RecoveryCodesView(TemplateView): 

1645 template_name = "accounts/recovery-codes.html" 

1646 

1647 def post(self, request, *args, **kwargs): 

1648 user = request.user 

1649 # Delete all existing tokens 

1650 StaticToken.objects.filter(device__user=user).delete() 

1651 

1652 # Get device 

1653 device = StaticDevice.objects.filter(user=user).first() 

1654 if device is None: 

1655 device = StaticDevice.objects.create(user=user, name="Backup Code") 

1656 

1657 # Generate tokens 

1658 for _i in range(16): 

1659 token = StaticToken.random_token() 

1660 device.token_set.create(token=token) 

1661 

1662 AuditLog.objects.create(user, request, "recovery-generate") 

1663 

1664 return redirect("recovery-codes") 

1665 

1666 def get(self, request, *args, **kwargs): 

1667 user = request.user 

1668 AuditLog.objects.create(user, request, "recovery-show") 

1669 return super().get(request, *args, **kwargs) 

1670 

1671 def get_context_data(self, **kwargs): 

1672 result = super().get_context_data(**kwargs) 

1673 user = self.request.user 

1674 recovery_codes = StaticToken.objects.filter(device__user=user).values_list( 

1675 "token", flat=True 

1676 ) 

1677 result["recovery_codes"] = recovery_codes 

1678 result["recovery_codes_str"] = "\n".join(recovery_codes) 

1679 return result 

1680 

1681 

1682@method_decorator(login_required, name="dispatch") 

1683class WebAuthnCredentialView(DetailView): 

1684 model = WebAuthnCredential 

1685 

1686 message_remove = gettext_lazy("The security key was removed.") 

1687 message_add = gettext_lazy("The security key %s was registered.") 

1688 

1689 def get_queryset(self): 

1690 return super().get_queryset().filter(user=self.request.user) 

1691 

1692 def get(self, request, *args, **kwargs): 

1693 return redirect_profile("#account") 

1694 

1695 def post(self, request, *args, **kwargs): 

1696 obj = self.get_object() 

1697 if "delete" in request.POST: 

1698 key_name = get_key_name(obj) 

1699 obj.delete() 

1700 AuditLog.objects.create( 

1701 request.user, request, "twofactor-remove", device=key_name 

1702 ) 

1703 messages.success(request, self.message_remove) 

1704 elif "name" in request.POST: 

1705 obj.name = request.POST["name"] 

1706 obj.save(update_fields=["name"]) 

1707 key_name = get_key_name(obj) 

1708 audit_pk = request.session.pop(SESSION_WEBAUTHN_AUDIT, None) 

1709 if isinstance(obj, WebAuthnCredential) and audit_pk is not None: 

1710 audit = AuditLog.objects.get(pk=audit_pk) 

1711 audit.params = {"device": key_name} 

1712 audit.save(update_fields=["params"]) 

1713 messages.success( 

1714 request, 

1715 self.message_add % key_name, 

1716 ) 

1717 return redirect_profile("#account") 

1718 

1719 

1720@method_decorator(login_required, name="dispatch") 

1721class TOTPDetailView(WebAuthnCredentialView): 

1722 model = TOTPDevice 

1723 

1724 message_remove = gettext_lazy("The authentication app was removed.") 

1725 message_add = gettext_lazy("The authentication app %s was registered.") 

1726 

1727 

1728@method_decorator(login_required, name="dispatch") 

1729class TOTPView(FormView): 

1730 template_name = "accounts/totp.html" 

1731 form_class = TOTPDeviceForm 

1732 session_key = SESSION_SECOND_FACTOR_TOTP 

1733 

1734 @cached_property 

1735 def totp_key(self) -> str: 

1736 key = self.request.session.get(self.session_key, None) 

1737 if key is None: 

1738 key = random_hex(20) 

1739 self.request.session[self.session_key] = key 

1740 return key 

1741 

1742 @cached_property 

1743 def totp_key_b32(self) -> str: 

1744 key = self.totp_key 

1745 rawkey = unhexlify(key.encode("ascii")) 

1746 return b32encode(rawkey).decode("utf-8") 

1747 

1748 @cached_property 

1749 def totp_url(self) -> str: 

1750 # For a complete run-through of all the parameters, have a look at the 

1751 # specs at: 

1752 # https://github.com/google/google-authenticator/wiki/Key-Uri-Format 

1753 

1754 accountname = self.request.user.username 

1755 issuer = settings.SITE_TITLE 

1756 label = f"{issuer}: {accountname}" 

1757 

1758 # Ensure that the secret parameter is the FIRST parameter of the URI, this 

1759 # allows Microsoft Authenticator to work. 

1760 query = ( 

1761 ("secret", self.totp_key_b32), 

1762 ("digits", "6"), 

1763 ("issuer", issuer), 

1764 ) 

1765 

1766 return f"otpauth://totp/{quote(label)}?{urlencode(query)}" 

1767 

1768 @property 

1769 def totp_svg(self): 

1770 image = qrcode.make(self.totp_url, image_factory=qrcode.image.svg.SvgPathImage) 

1771 return mark_safe(image.to_string(encoding="unicode")) # noqa: S308 

1772 

1773 def get_context_data(self, **kwargs): 

1774 """Create context for rendering page.""" 

1775 context = super().get_context_data(**kwargs) 

1776 context["totp_key_b32"] = self.totp_key_b32 

1777 context["totp_url"] = self.totp_url 

1778 context["totp_svg"] = self.totp_svg 

1779 return context 

1780 

1781 def get_form_kwargs(self): 

1782 kwargs = super().get_form_kwargs() 

1783 kwargs["key"] = self.totp_key 

1784 kwargs["user"] = self.request.user 

1785 return kwargs 

1786 

1787 def form_valid(self, form: TOTPDeviceForm): 

1788 user = self.request.user 

1789 device = form.save() 

1790 AuditLog.objects.create( 

1791 user, 

1792 self.request, 

1793 "twofactor-add", 

1794 device=get_key_name(device), 

1795 ) 

1796 if form.cleaned_data["remove_previous"]: 

1797 for old in user.totpdevice_set.exclude(pk=device.pk): 

1798 key_name = get_key_name(old) 

1799 old.delete() 

1800 AuditLog.objects.create( 

1801 user, self.request, "twofactor-remove", device=key_name 

1802 ) 

1803 

1804 return redirect_profile("#account") 

1805 

1806 

1807class SecondFactorMixin(View): 

1808 def second_factor_completed(self, device: Device) -> None: 

1809 # Store audit log entry about used device and update last used device type 

1810 user = self.get_user() 

1811 user.profile.log_2fa(self.request, device) 

1812 del self.request.session[SESSION_SECOND_FACTOR_USER] 

1813 

1814 if not self.request.session.get(SESSION_SECOND_FACTOR_SOCIAL): 

1815 # Keep login on social pipeline if we got here from it 

1816 auth_login(self.request, user) 

1817 # Perform OTP login 

1818 otp_login(self.request, device) 

1819 else: 

1820 self.request.session[DEVICE_ID_SESSION_KEY] = device.persistent_id 

1821 # This is completed in social_complete after completing social login 

1822 

1823 def second_factor_failed(self) -> None: 

1824 user = self.get_user() 

1825 user.profile.log_2fa_failed(self.request, self.get_backend()) 

1826 

1827 def get_user(self) -> User: 

1828 try: 

1829 user_id, backend = self.request.session[SESSION_SECOND_FACTOR_USER] 

1830 except KeyError as error: 

1831 raise Http404 from error 

1832 try: 

1833 user = User.objects.get(pk=user_id) 

1834 except User.DoesNotExist as error: 

1835 raise Http404 from error 

1836 user.backend = backend 

1837 return user 

1838 

1839 

1840class SecondFactorLoginView(SecondFactorMixin, RedirectURLMixin, FormView): 

1841 template_name = "accounts/2fa.html" 

1842 next_page = settings.LOGIN_REDIRECT_URL 

1843 forms: ClassVar[dict[str, Form]] = { 

1844 "totp": TOTPTokenForm, 

1845 "webauthn": WebAuthnTokenForm, 

1846 "recovery": OTPTokenForm, 

1847 } 

1848 

1849 def get_backend(self) -> DeviceType: 

1850 backend = self.kwargs["backend"] 

1851 if backend not in self.forms: 

1852 raise Http404 

1853 return backend 

1854 

1855 def get_form_class(self): 

1856 return self.forms[self.get_backend()] 

1857 

1858 def get_form_kwargs(self): 

1859 kwargs = super().get_form_kwargs() 

1860 kwargs["user"] = self.user 

1861 kwargs["request"] = self.request 

1862 return kwargs 

1863 

1864 def get_context_data(self, **kwargs): 

1865 """Create context for rendering page.""" 

1866 context = super().get_context_data(**kwargs) 

1867 context["second_factor_types"] = self.user.profile.second_factor_types - { 

1868 self.get_backend() 

1869 } 

1870 context["next"] = self.request.GET.get("next", "") 

1871 context["title"] = gettext("Second factor sign in") 

1872 return context 

1873 

1874 @method_decorator(session_ratelimit_post("second_factor")) 

1875 def dispatch(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

1876 self.user = self.get_user() 

1877 return super().dispatch(request, *args, **kwargs) 

1878 

1879 def form_valid(self, form): 

1880 self.second_factor_completed(form.user.otp_device) 

1881 

1882 return HttpResponseRedirect(self.get_success_url()) 

1883 

1884 def form_invalid(self, form): 

1885 self.second_factor_failed() 

1886 return super().form_invalid(form) 

1887 

1888 

1889class WeblateBeginCredentialAuthenticationView( 

1890 SecondFactorMixin, BeginCredentialAuthenticationView 

1891): 

1892 pass 

1893 

1894 

1895@method_decorator(session_ratelimit_post("second_factor"), name="dispatch") 

1896class WeblateCompleteCredentialAuthenticationView( 

1897 SecondFactorMixin, CompleteCredentialAuthenticationView 

1898): 

1899 def complete_auth(self, device: WebAuthnCredential) -> User: 

1900 return self.second_factor_completed(device) 

1901 

1902 def post(self, *args, **kwargs): 

1903 try: 

1904 return super().post(*args, **kwargs) 

1905 except OTPWebAuthnApiError: 

1906 self.second_factor_failed() 

1907 raise