Coverage for app/venv/lib/python3.14/site-packages/weblate/accounts/forms.py: 29%
631 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« 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
5from __future__ import annotations
7import base64
8import json
9from binascii import unhexlify
10from datetime import datetime, timedelta
11from time import time
12from typing import TYPE_CHECKING, ClassVar, cast
14from altcha import ChallengeOptions, create_challenge, verify_solution
15from crispy_forms.helper import FormHelper
16from crispy_forms.layout import HTML, Div, Field, Fieldset, Layout, Submit
17from django import forms
18from django.conf import settings
19from django.contrib.auth import authenticate, password_validation
20from django.contrib.auth.forms import SetPasswordForm as DjangoSetPasswordForm
21from django.db import transaction
22from django.middleware.csrf import rotate_token
23from django.utils.functional import cached_property
24from django.utils.html import escape, format_html
25from django.utils.translation import activate, gettext, gettext_lazy, ngettext, pgettext
26from django_otp.forms import OTPTokenForm as DjangoOTPTokenForm
27from django_otp.forms import otp_verification_failed
28from django_otp.oath import totp
29from django_otp.plugins.otp_static.models import StaticDevice
30from django_otp.plugins.otp_totp.models import TOTPDevice
32from weblate.accounts.auth import try_get_user
33from weblate.accounts.captcha import MathCaptcha
34from weblate.accounts.models import AuditLog, Profile
35from weblate.accounts.notifications import NOTIFICATIONS, NotificationScope
36from weblate.accounts.utils import (
37 adjust_session_expiry,
38 cycle_session_keys,
39 get_all_user_mails,
40 invalidate_reset_codes,
41)
42from weblate.auth.models import Group, User
43from weblate.lang.models import Language
44from weblate.logger import LOGGER
45from weblate.trans.defines import FULLNAME_LENGTH
46from weblate.trans.models import Component, Project
47from weblate.utils import messages
48from weblate.utils.forms import (
49 ContextDiv,
50 EmailField,
51 QueryField,
52 SortedSelect,
53 SortedSelectMultiple,
54 UsernameField,
55)
56from weblate.utils.ratelimit import check_rate_limit, get_rate_setting, reset_rate_limit
57from weblate.utils.validators import validate_fullname
59if TYPE_CHECKING: 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 from altcha import Challenge
61 from django_otp.models import Device
62 from django_stubs_ext import StrOrPromise
64 from weblate.auth.models import AuthenticatedHttpRequest
67class UniqueEmailMixin(forms.Form):
68 validate_unique_mail = False
70 def clean_email(self):
71 """Validate whether email address is not already in use."""
72 self.cleaned_data["email_user"] = None
73 mail = self.cleaned_data["email"]
74 users = User.objects.filter(
75 email=mail,
76 is_active=True,
77 is_bot=False,
78 )
79 if not users:
80 users = User.objects.filter(
81 social_auth__verifiedemail__email__iexact=mail,
82 is_active=True,
83 is_bot=False,
84 )
85 if users:
86 self.cleaned_data["email_user"] = users[0]
87 if self.validate_unique_mail:
88 raise forms.ValidationError(
89 gettext(
90 "This e-mail address is already in use. "
91 "Please supply a different e-mail address."
92 )
93 )
94 return self.cleaned_data["email"]
97class PasswordField(forms.CharField):
98 """Password field."""
100 def __init__(self, new_password: bool = False, **kwargs) -> None:
101 kwargs["widget"] = forms.PasswordInput(
102 attrs={
103 "autocomplete": "new-password" if new_password else "current-password"
104 },
105 render_value=False,
106 )
107 kwargs["max_length"] = settings.MAXIMAL_PASSWORD_LENGTH
108 kwargs["strip"] = False
109 super().__init__(**kwargs)
112class UniqueUsernameField(UsernameField):
113 def clean(self, value):
114 """Username validation, requires a unique name."""
115 if value is None:
116 return None
117 if value is not None:
118 existing = User.objects.filter(username=value)
119 if existing.exists() and value != self.valid:
120 raise forms.ValidationError(
121 gettext(
122 "This username is already taken. Please pick something else."
123 )
124 )
126 return super().clean(value)
129class FullNameField(forms.CharField):
130 default_validators = [validate_fullname] # noqa: RUF012
132 def __init__(self, *args, **kwargs) -> None:
133 kwargs["max_length"] = FULLNAME_LENGTH
134 kwargs["label"] = gettext_lazy("Full name")
135 kwargs["help_text"] = gettext_lazy(
136 "Name is also used in version control commits."
137 )
138 kwargs["required"] = True
139 super().__init__(*args, **kwargs)
142class ProfileBaseForm(forms.ModelForm):
143 @classmethod
144 def from_request(cls, request: AuthenticatedHttpRequest):
145 if request.method == "POST":
146 return cls(request.POST, instance=request.user.profile)
147 return cls(instance=request.user.profile)
149 def add_error(self, field, error) -> None:
150 if field is None and hasattr(error, "error_dict"):
151 # Skip errors from model clean method on unknown fields as
152 # this is partial form. This is really bound to how Profile.clean
153 # behaves.
154 ignored_fields = ("dashboard_component_list", "dashboard_view")
155 for field_name in error.error_dict:
156 if field_name in ignored_fields and not hasattr(self, field_name):
157 return
158 super().add_error(field, error)
161class LanguagesForm(ProfileBaseForm):
162 """User profile editing."""
164 class Meta:
165 model = Profile
166 fields = ("language", "languages", "secondary_languages")
167 widgets = { # noqa: RUF012
168 "language": SortedSelect,
169 "languages": SortedSelectMultiple,
170 "secondary_languages": SortedSelectMultiple,
171 }
173 def __init__(self, *args, **kwargs) -> None:
174 super().__init__(*args, **kwargs)
175 # Remove empty choice from the form. We need it at the database level
176 # to initialize user profile, but it is filled in later based on
177 # languages configured in the browser.
178 self.fields["language"].choices = [
179 choice for choice in self.fields["language"].choices if choice[0]
180 ]
181 # Limit languages to ones which have translation, do this by generating choices
182 # instead of queryset as the queryset would be evaluated twice as
183 # ModelChoiceField copies the queryset
184 languages = Language.objects.have_translation()
185 choices = list(languages.as_choices(use_code=False))
186 self.fields["languages"].choices = choices
187 self.fields["secondary_languages"].choices = choices
188 self.helper = FormHelper(self)
189 self.helper.disable_csrf = True
190 self.helper.form_tag = False
191 self.helper.template_pack = "bootstrap5"
193 def save(self, commit=True) -> None:
194 super().save(commit=commit)
195 # Activate selected language
196 activate(self.cleaned_data["language"])
199class CommitForm(ProfileBaseForm):
200 commit_email = forms.ChoiceField(
201 label=gettext_lazy("Commit e-mail"),
202 choices=[("", gettext_lazy("Use account e-mail address"))],
203 help_text=gettext_lazy(
204 "Used in version-control commits. The address stays in the repository forever once changes are committed by Weblate."
205 ),
206 required=False,
207 widget=forms.RadioSelect,
208 )
210 class Meta:
211 model = Profile
212 fields = ("commit_email",)
214 def __init__(self, *args, **kwargs) -> None:
215 super().__init__(*args, **kwargs)
217 commit_emails = get_all_user_mails(self.instance.user, filter_deliverable=False)
218 site_commit_email = self.instance.get_site_commit_email()
219 if site_commit_email:
220 if not settings.PRIVATE_COMMIT_EMAIL_OPT_IN:
221 self.fields["commit_email"].choices = [("", site_commit_email)]
222 else:
223 commit_emails.add(site_commit_email)
225 self.fields["commit_email"].choices += [(x, x) for x in sorted(commit_emails)]
227 self.helper = FormHelper(self)
228 self.helper.disable_csrf = True
229 self.helper.form_tag = False
230 self.helper.template_pack = "bootstrap5"
233class ProfileForm(ProfileBaseForm):
234 """User profile editing."""
236 public_email = forms.ChoiceField(
237 label=gettext_lazy("Public e-mail"),
238 choices=[("", gettext_lazy("Hide e-mail address from public view"))],
239 required=False,
240 )
242 class Meta:
243 model = Profile
244 fields = (
245 "website",
246 "contact",
247 "public_email",
248 "liberapay",
249 "codesite",
250 "github",
251 "fediverse",
252 "twitter",
253 "linkedin",
254 "location",
255 "company",
256 )
258 def __init__(self, *args, **kwargs) -> None:
259 super().__init__(*args, **kwargs)
260 emails = get_all_user_mails(self.instance.user)
262 self.fields["public_email"].choices += [(x, x) for x in sorted(emails)]
264 self.helper = FormHelper(self)
265 self.helper.disable_csrf = True
266 self.helper.form_tag = False
267 self.helper.template_pack = "bootstrap5"
270class SubscriptionForm(ProfileBaseForm):
271 """User watched projects management."""
273 class Meta:
274 model = Profile
275 fields = (
276 "auto_watch",
277 "watched",
278 )
279 widgets = { # noqa: RUF012
280 "watched": forms.SelectMultiple,
281 }
283 def __init__(self, *args, **kwargs) -> None:
284 super().__init__(*args, **kwargs)
285 user = kwargs["instance"].user
286 self.fields["watched"].required = False
287 self.fields["watched"].queryset = user.allowed_projects
288 # Create a mapping of project IDs to slugs
289 project_slug_map = {str(p.id): p.slug for p in user.allowed_projects}
290 # Add the data attribute with the JSON mapping
291 self.fields["watched"].widget.attrs["data-project-slugs"] = json.dumps(
292 project_slug_map
293 )
294 self.helper = FormHelper(self)
295 self.helper.disable_csrf = True
296 self.helper.form_tag = False
297 self.helper.template_pack = "bootstrap5"
300class UserSettingsForm(ProfileBaseForm):
301 """User settings form."""
303 class Meta:
304 model = Profile
305 fields = (
306 "theme",
307 "hide_completed",
308 "translate_mode",
309 "zen_mode",
310 "nearby_strings",
311 "secondary_in_zen",
312 "hide_source_secondary",
313 "editor_link",
314 "special_chars",
315 "contribute_personal_tm",
316 )
318 def __init__(self, *args, **kwargs) -> None:
319 super().__init__(*args, **kwargs)
320 self.fields["special_chars"].strip = False
321 self.helper = FormHelper(self)
322 self.helper.disable_csrf = True
323 self.helper.form_tag = False
324 self.helper.template_pack = "bootstrap5"
327class DashboardSettingsForm(ProfileBaseForm):
328 """Dashboard settings form."""
330 class Meta:
331 model = Profile
332 fields = ("dashboard_view", "dashboard_component_list")
333 widgets = { # noqa: RUF012
334 "dashboard_view": forms.RadioSelect,
335 "dashboard_component_list": forms.HiddenInput,
336 }
338 def __init__(self, *args, **kwargs) -> None:
339 super().__init__(*args, **kwargs)
340 self.helper = FormHelper(self)
341 self.helper.disable_csrf = True
342 self.helper.form_tag = False
343 self.helper.template_pack = "bootstrap5"
344 component_lists = self.instance.allowed_dashboard_component_lists
345 self.fields["dashboard_component_list"].queryset = component_lists
346 choices = [
347 choice
348 for choice in self.fields["dashboard_view"].choices
349 if choice[0] != Profile.DASHBOARD_COMPONENT_LIST
350 ]
351 if not component_lists:
352 choices = [
353 choice
354 for choice in choices
355 if choice[0] != Profile.DASHBOARD_COMPONENT_LISTS
356 ]
357 choices.extend(
358 (100 + clist.id, gettext("Component list: %s") % clist.name)
359 for clist in component_lists
360 )
361 self.fields["dashboard_view"].choices = choices
362 if (
363 self.instance.dashboard_view == Profile.DASHBOARD_COMPONENT_LIST
364 and self.instance.dashboard_component_list
365 ):
366 self.initial["dashboard_view"] = (
367 100 + self.instance.dashboard_component_list_id
368 )
370 def clean(self) -> None:
371 view = self.cleaned_data.get("dashboard_view")
372 if view and view >= 100:
373 self.cleaned_data["dashboard_view"] = Profile.DASHBOARD_COMPONENT_LIST
374 view -= 100
375 for clist in self.instance.allowed_dashboard_component_lists:
376 if clist.id == view:
377 self.cleaned_data["dashboard_component_list"] = clist
378 break
381class UserForm(forms.ModelForm):
382 """User information form."""
384 email = forms.ChoiceField(
385 label=gettext_lazy("Account e-mail"),
386 help_text=gettext_lazy(
387 "Used for e-mail notifications and as a commit e-mail if it is not configured below."
388 ),
389 choices=(("", ""),),
390 required=True,
391 widget=forms.RadioSelect,
392 )
394 class Meta:
395 model = User
396 fields = ("username", "full_name", "email")
397 field_classes = { # noqa: RUF012
398 "username": UniqueUsernameField,
399 "full_name": FullNameField,
400 }
402 def __init__(self, *args, **kwargs) -> None:
403 super().__init__(*args, **kwargs)
405 emails = get_all_user_mails(self.instance)
407 self.fields["email"].choices = [(x, x) for x in sorted(emails)]
408 self.fields["username"].valid = self.instance.username
410 self.helper = FormHelper(self)
411 self.helper.disable_csrf = True
412 self.helper.form_tag = False
413 self.helper.template_pack = "bootstrap5"
415 @classmethod
416 def from_request(cls, request: AuthenticatedHttpRequest):
417 if request.method == "POST":
418 return cls(request.POST, instance=request.user)
419 return cls(instance=request.user)
421 def audit(self, request: AuthenticatedHttpRequest) -> None:
422 orig = User.objects.get(pk=self.instance.pk)
423 for attr in ("username", "full_name", "email"):
424 orig_attr = getattr(orig, attr)
425 new_attr = getattr(self.instance, attr)
426 if orig_attr != new_attr:
427 AuditLog.objects.create(
428 orig, request, attr, old=orig_attr, new=new_attr
429 )
432class CaptchaWidget(forms.TextInput):
433 challenge: Challenge | None = None
435 def render(self, name, value, attrs=None, renderer=None, **kwargs):
436 if self.challenge is None:
437 msg = "Challenge is missing!"
438 raise ValueError(msg)
440 return format_html(
441 "<altcha-widget challengejson='{}' strings='{}' hidefooter auto='onfocus'></altcha-widget>",
442 # Directly include challenge
443 json.dumps(
444 {
445 "algorithm": self.challenge.algorithm,
446 "challenge": self.challenge.challenge,
447 "maxnumber": self.challenge.max_number,
448 "salt": self.challenge.salt,
449 "signature": self.challenge.signature,
450 }
451 ),
452 # Localize strings
453 json.dumps(
454 {
455 "error": gettext("Verification failed. Try again later."),
456 "expired": gettext("Verification expired. Try again."),
457 "label": gettext("I'm not a robot"),
458 "verified": gettext("Verification completed"),
459 "verifying": gettext("Verifying…"),
460 "waitAlert": gettext(
461 "Verification is still in progress, please wait."
462 ),
463 }
464 ),
465 )
468class CaptchaForm(forms.Form):
469 captcha = forms.IntegerField(required=True)
470 altcha = forms.CharField(
471 required=True, widget=CaptchaWidget, label=gettext_lazy("Human verification")
472 )
474 @staticmethod
475 def is_altcha_available():
476 # Altcha requires secure context in browser, which is available
477 # with HTTPS or on localhost
478 return settings.ENABLE_HTTPS or settings.SITE_DOMAIN.rsplit(":", 1)[0] in {
479 "localhost",
480 "127.0.0.1",
481 }
483 def __init__(
484 self,
485 *,
486 request: AuthenticatedHttpRequest,
487 hide_captcha: bool = False,
488 data=None,
489 initial=None,
490 ) -> None:
491 super().__init__(data=data, initial=initial)
492 self.request = request
493 self.challenge: Challenge | None = None
495 # Possibly hide fields
496 if not settings.REGISTRATION_CAPTCHA or hide_captcha:
497 self.fields["altcha"].widget = forms.HiddenInput()
498 self.fields["altcha"].required = False
499 self.fields["captcha"].widget = forms.HiddenInput()
500 self.fields["captcha"].required = False
501 elif not self.is_altcha_available():
502 self.fields["altcha"].widget = forms.HiddenInput()
503 self.fields["altcha"].required = False
505 # Initialize captcha if required
506 if self.fields["captcha"].required:
507 if data is None or "captcha" not in request.session:
508 self.generate_captcha()
509 else:
510 self.mathcaptcha = MathCaptcha.unserialize(request.session["captcha"])
511 self.set_label()
513 # Initialize altcha if required
514 if self.fields["altcha"].required:
515 self.generate_challenge()
516 self.fields["altcha"].widget.challenge = self.challenge
517 if data is None:
518 self.store_challenge()
520 def generate_challenge(self) -> Challenge:
521 # The expires timestamp needs to be in the local time and not
522 # timezone aware because it is converted using time.mktime(expires.timetuple())
523 # and then compared to time.time()
524 expires = datetime.now(tz=None) + timedelta(hours=1) # noqa: DTZ005
526 challenge_options = ChallengeOptions(
527 hmac_key=settings.SECRET_KEY,
528 max_number=settings.ALTCHA_MAX_NUMBER,
529 expires=expires,
530 )
531 self.challenge = create_challenge(challenge_options)
532 return self.challenge
534 def generate_captcha(self) -> None:
535 self.mathcaptcha = MathCaptcha()
536 self.request.session["captcha"] = self.mathcaptcha.serialize()
537 self.set_label()
539 def set_label(self) -> None:
540 """Set correct math captcha label."""
541 self.fields["captcha"].label = format_html(
542 pgettext(
543 "Question for a mathematics-based CAPTCHA, "
544 "the %s is an arithmetic problem",
545 "What is %s?",
546 ).replace("%s", "{}"),
547 self.mathcaptcha.display,
548 )
549 if self.is_bound:
550 self["captcha"].label = cast("str", self.fields["captcha"].label)
552 def store_challenge(self) -> None:
553 self.request.session["captcha_challenge"] = self.challenge.challenge
555 def clean_captcha(self) -> None:
556 """Validate math captcha."""
557 if not self.fields["altcha"].required and not self.fields["captcha"].required:
558 return
559 if not self.mathcaptcha.validate(self.cleaned_data["captcha"]):
560 self.generate_captcha()
561 rotate_token(self.request)
562 raise forms.ValidationError(
563 # Translators: Shown on wrong answer to the mathematics-based CAPTCHA
564 gettext("That was not correct, please try again.")
565 )
567 def clean_altcha(self) -> None:
568 """Validate altcha."""
569 if not self.fields["altcha"].required:
570 return
571 payload = self.data.get("altcha", "")
573 # Validate payload
574 result = verify_solution(payload, settings.SECRET_KEY, check_expires=True)
575 if not result[0]:
576 LOGGER.error("Invalid altcha solution: %s", result[1:])
577 raise forms.ValidationError(gettext("Validation failed, please try again."))
579 # Manually guard against replay attacks
580 payload = json.loads(base64.b64decode(payload).decode())
581 # Use get to gracefully handle already solved challenges
582 if payload["challenge"] != self.request.session.get("captcha_challenge"):
583 LOGGER.error("Outdated altcha solution")
584 raise forms.ValidationError(gettext("Validation failed, please try again."))
586 def is_valid(self) -> bool:
587 result = super().is_valid()
588 if result:
589 self.cleanup_session()
590 elif self.fields["altcha"].required:
591 self.store_challenge()
592 return result
594 def cleanup_session(self) -> None:
595 self.request.session.pop("captcha", None)
596 self.request.session.pop("captcha_challenge", None)
599class ContactForm(CaptchaForm):
600 """Form for contacting site owners."""
602 subject = forms.CharField(
603 label=gettext_lazy("Subject"), required=True, max_length=100
604 )
605 name = forms.CharField(
606 label=gettext_lazy("Your name"), required=True, max_length=FULLNAME_LENGTH
607 )
608 email = EmailField(label=gettext_lazy("Your e-mail"), required=True)
609 message = forms.CharField(
610 label=gettext_lazy("Message"),
611 required=True,
612 help_text=gettext_lazy(
613 "Please contact us in English. Otherwise, we might "
614 "not process your request."
615 ),
616 max_length=2000,
617 widget=forms.Textarea,
618 )
620 field_order = [ # noqa: RUF012
621 "subject",
622 "name",
623 "email",
624 "message",
625 "captcha",
626 "altcha",
627 ]
630class EmailForm(CaptchaForm, UniqueEmailMixin):
631 """Email change form."""
633 required_css_class = "required"
634 error_css_class = "error"
636 email = EmailField(
637 label=gettext_lazy("E-mail"),
638 help_text=gettext_lazy("An e-mail with a confirmation link will be sent here."),
639 )
641 field_order = [ # noqa: RUF012
642 "email",
643 "captcha",
644 "altcha",
645 ]
648class RegistrationForm(EmailForm):
649 """Registration form."""
651 required_css_class = "required"
652 error_css_class = "error"
654 username = UniqueUsernameField()
655 # This has to be without underscore for social-auth
656 fullname = FullNameField()
658 def __init__(
659 self, request=None, data=None, initial=None, hide_captcha: bool = False
660 ) -> None:
661 # The 'request' parameter is set for custom auth use by subclasses.
662 # The form data comes in via the standard 'data' kwarg.
663 self.request = request
664 super().__init__(
665 request=request, data=data, initial=initial, hide_captcha=hide_captcha
666 )
667 self.helper = FormHelper(self)
668 self.helper.form_tag = False
669 self.helper.layout = Layout(
670 "email",
671 "username",
672 "fullname",
673 "captcha",
674 "altcha",
675 ContextDiv(template="accounts/register-password.html"),
676 )
678 def clean(self):
679 if not check_rate_limit("registration", self.request):
680 lockout_period = get_rate_setting("registration", "LOCKOUT") // 60
681 raise forms.ValidationError(
682 ngettext(
683 (
684 "Too many failed registration attempts from this location. "
685 "Please try again in %d minute."
686 ),
687 (
688 "Too many failed registration attempts from this location. "
689 "Please try again in %d minutes."
690 ),
691 lockout_period,
692 )
693 % lockout_period
694 )
695 return self.cleaned_data
698class SetPasswordForm(DjangoSetPasswordForm):
699 new_password1 = PasswordField(
700 label=gettext_lazy("New password"),
701 help_text=password_validation.password_validators_help_text_html(),
702 new_password=True,
703 )
704 new_password2 = PasswordField(
705 label=gettext_lazy("New password confirmation"),
706 new_password=True,
707 )
709 @transaction.atomic
710 def save(self, request: AuthenticatedHttpRequest, delete_session=False) -> None:
711 AuditLog.objects.create(
712 self.user,
713 request,
714 "password",
715 password=self.user.password,
716 method="changed" if self.user.has_usable_password() else "configured",
717 )
718 # Change the password
719 password = self.cleaned_data["new_password1"]
720 self.user.set_password(password)
721 self.user.save(update_fields=["password"])
723 # Updating the password logs out all other sessions for the user
724 # except the current one and change key for current session
725 cycle_session_keys(request, self.user)
727 # Invalidate password reset codes
728 invalidate_reset_codes(self.user)
730 if delete_session:
731 request.session.flush()
733 messages.success(request, gettext("Your password has been changed."))
736class EmptyConfirmForm(forms.Form):
737 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
738 self.request = request
739 self.user = request.user
740 if "user" in kwargs:
741 self.user = kwargs.pop("user")
742 super().__init__(*args, **kwargs)
745class PasswordConfirmForm(EmptyConfirmForm):
746 password = PasswordField(
747 label=gettext_lazy("Current password"),
748 help_text=gettext_lazy("Leave empty if you have not set a password yet."),
749 required=False,
750 )
752 def clean_password(self) -> None:
753 cur_password = self.cleaned_data["password"]
754 valid = False
755 if self.user.has_usable_password():
756 valid = self.user.check_password(cur_password)
757 elif not cur_password:
758 valid = True
759 if not valid:
760 rotate_token(self.request)
761 raise forms.ValidationError(
762 gettext("You have entered an invalid password.")
763 )
766class ResetForm(EmailForm):
767 def clean_email(self):
768 if self.cleaned_data["email"] == "noreply@weblate.org":
769 msg = "No password reset for deleted or anonymous user."
770 raise forms.ValidationError(msg)
771 return super().clean_email()
774class LoginForm(forms.Form):
775 username = forms.CharField(max_length=254, label=gettext_lazy("Username or e-mail"))
776 password = PasswordField(label=gettext_lazy("Password"))
778 error_messages = { # noqa: RUF012
779 "invalid_login": gettext_lazy(
780 "Please enter the correct username and password."
781 ),
782 "inactive": gettext_lazy("This account is inactive."),
783 }
785 def __init__(self, request=None, *args, **kwargs) -> None:
786 # The 'request' parameter is set for custom auth use by subclasses.
787 # The form data comes in via the standard 'data' kwarg.
788 self.request = request
789 self.user_cache: User | None = None
790 super().__init__(*args, **kwargs)
792 def clean(self):
793 username = self.cleaned_data.get("username")
794 password = self.cleaned_data.get("password")
796 if username and password:
797 if not check_rate_limit("login", self.request):
798 lockout_period = get_rate_setting("login", "LOCKOUT") // 60
799 raise forms.ValidationError(
800 ngettext(
801 (
802 "Too many authentication attempts from this location. "
803 "Please try again in %d minute."
804 ),
805 (
806 "Too many authentication attempts from this location. "
807 "Please try again in %d minutes."
808 ),
809 lockout_period,
810 )
811 % lockout_period
812 )
813 user = self.user_cache = cast(
814 "User | None",
815 authenticate(self.request, username=username, password=password),
816 )
817 if user is None:
818 for failed_user in try_get_user(username, True):
819 audit = AuditLog.objects.create(
820 failed_user,
821 self.request,
822 "failed-auth",
823 method="password",
824 name=username,
825 )
826 audit.check_rate_limit(self.request)
827 rotate_token(self.request)
828 raise forms.ValidationError(
829 self.error_messages["invalid_login"], code="invalid_login"
830 )
831 if not user.is_active or user.is_bot:
832 raise forms.ValidationError(
833 self.error_messages["inactive"], code="inactive"
834 )
835 AuditLog.objects.create(
836 user, self.request, "login", method="password", name=username
837 )
838 adjust_session_expiry(request=self.request, user=user)
839 reset_rate_limit("login", self.request)
840 return self.cleaned_data
842 def get_user(self):
843 return self.user_cache
846class AdminLoginForm(LoginForm):
847 def clean(self):
848 data = super().clean()
849 if self.user_cache and not self.user_cache.is_superuser:
850 raise forms.ValidationError(
851 self.error_messages["inactive"], code="inactive"
852 )
853 return data
856class NotificationForm(forms.Form):
857 scope = forms.ChoiceField(
858 choices=NotificationScope.choices, widget=forms.HiddenInput, required=True
859 )
860 project = forms.ModelChoiceField(
861 widget=forms.HiddenInput, queryset=Project.objects.none(), required=False
862 )
863 component = forms.ModelChoiceField(
864 widget=forms.HiddenInput, queryset=Component.objects.none(), required=False
865 )
867 def __init__(
868 self, *, user, show_default, removable, subscriptions, is_active, **kwargs
869 ) -> None:
870 super().__init__(**kwargs)
871 self.user = user
872 self.is_active = is_active
873 self.removable = removable
874 self.show_default = show_default
875 self.fields["project"].queryset = user.allowed_projects
876 self.fields["component"].queryset = Component.objects.filter_access(user)
877 language_fields = []
878 component_fields = []
879 for field, notification_cls in self.notification_fields():
880 self.fields[field] = forms.ChoiceField(
881 label=notification_cls.verbose,
882 choices=self.get_choices(notification_cls, show_default),
883 required=False,
884 initial=self.get_initial(notification_cls, subscriptions, show_default),
885 )
886 if notification_cls.filter_languages:
887 language_fields.append(field)
888 else:
889 component_fields.append(field)
890 self.helper = FormHelper(self)
891 self.helper.disable_csrf = True
892 self.helper.form_tag = False
893 self.helper.template_pack = "bootstrap5"
894 self.helper.label_class = "col-3"
895 self.helper.field_class = "col-9"
896 self.helper.form_class = "form-horizontal"
897 self.helper.layout = Layout(
898 "scope",
899 "project",
900 "component",
901 Fieldset(
902 gettext("Component wide notifications"),
903 HTML(escape(self.get_help_component())),
904 *component_fields,
905 ),
906 Fieldset(
907 gettext("Translation notifications"),
908 HTML(escape(self.get_help_translation())),
909 *language_fields,
910 ),
911 )
913 @staticmethod
914 def notification_fields():
915 for notification_cls in NOTIFICATIONS:
916 yield (f"notify-{notification_cls.get_name()}", notification_cls)
918 @staticmethod
919 def get_initial(notification_cls, subscriptions, show_default):
920 return subscriptions.get(notification_cls.get_name(), -1 if show_default else 0)
922 @staticmethod
923 def get_choices(notification_cls, show_default):
924 result = []
925 if show_default:
926 result.append((-1, gettext("Use default setting")))
927 result.extend(notification_cls.get_freq_choices())
928 return result
930 @cached_property
931 def form_params(self):
932 if self.is_bound:
933 self.is_valid()
934 return self.cleaned_data
935 return self.initial
937 def get_form_param(self, name: str, default):
938 result = self.form_params.get(name)
939 if result is not None:
940 return result
941 return self.initial.get(name, default)
943 @cached_property
944 def form_scope(self):
945 return int(self.get_form_param("scope", NotificationScope.SCOPE_WATCHED))
947 @cached_property
948 def form_project(self):
949 return self.get_form_param("project", None)
951 @cached_property
952 def form_component(self):
953 return self.get_form_param("component", None)
955 def get_name(self):
956 scope = self.form_scope
957 if scope == NotificationScope.SCOPE_ALL:
958 return gettext("Other projects")
959 if scope == NotificationScope.SCOPE_WATCHED:
960 return gettext("Watched projects")
961 if scope == NotificationScope.SCOPE_ADMIN:
962 return gettext("Managed projects")
963 if scope == NotificationScope.SCOPE_PROJECT:
964 return gettext("Project: {}").format(self.form_project)
965 return gettext("Component: {}").format(self.form_component)
967 def get_help_component(self):
968 scope = self.form_scope
969 if scope == NotificationScope.SCOPE_ALL:
970 return gettext(
971 "You will receive a notification for every such event"
972 " in non-watched projects."
973 )
974 if scope == NotificationScope.SCOPE_WATCHED:
975 return gettext(
976 "You will receive a notification for every such event"
977 " in your watched projects."
978 )
979 if scope == NotificationScope.SCOPE_ADMIN:
980 return gettext(
981 "You will receive a notification for every such event"
982 " in projects where you have admin permissions."
983 )
984 if scope == NotificationScope.SCOPE_PROJECT:
985 return gettext(
986 "You will receive a notification for every such event in %(project)s."
987 ) % {"project": self.form_project}
988 return gettext(
989 "You will receive a notification for every such event in %(component)s."
990 ) % {"component": self.form_component}
992 def get_help_translation(self):
993 scope = self.form_scope
994 if scope == NotificationScope.SCOPE_ALL:
995 return gettext(
996 "You will only receive these notifications for your translated "
997 "languages in non-watched projects."
998 )
999 if scope == NotificationScope.SCOPE_WATCHED:
1000 return gettext(
1001 "You will only receive these notifications for your translated "
1002 "languages in your watched projects."
1003 )
1004 if scope == NotificationScope.SCOPE_ADMIN:
1005 return gettext(
1006 "You will only receive these notifications for your translated "
1007 "languages in projects where you have admin permissions."
1008 )
1009 if scope == NotificationScope.SCOPE_PROJECT:
1010 return gettext(
1011 "You will only receive these notifications for your"
1012 " translated languages in %(project)s."
1013 ) % {"project": self.form_project}
1014 return gettext(
1015 "You will only receive these notifications for your"
1016 " translated languages in %(component)s."
1017 ) % {"component": self.form_component}
1019 def save(self) -> None:
1020 # Lookup for this form
1021 lookup = {
1022 "scope": self.cleaned_data["scope"],
1023 "project": self.cleaned_data["project"],
1024 "component": self.cleaned_data["component"],
1025 }
1026 handled = set()
1027 for field, notification_cls in self.notification_fields():
1028 frequency = self.cleaned_data[field]
1029 # We do not store removed field, defaults or disabled default subscriptions
1030 if frequency in {"", "-1"} or (frequency == "0" and not self.show_default):
1031 continue
1032 # Create/Get from database
1033 subscription, _created = self.user.subscription_set.update_or_create(
1034 notification=notification_cls.get_name(),
1035 defaults={"frequency": frequency},
1036 **lookup,
1037 )
1038 handled.add(subscription.pk)
1039 # Delete stale subscriptions
1040 self.user.subscription_set.filter(**lookup).exclude(pk__in=handled).delete()
1043class UserSearchForm(forms.Form):
1044 """User searching form."""
1046 q = QueryField(parser="user")
1047 sort_by = forms.CharField(required=False, widget=forms.HiddenInput)
1049 sort_choices: ClassVar[dict[str, StrOrPromise]] = {
1050 "username": gettext_lazy("Username"),
1051 "full_name": gettext_lazy("Full name"),
1052 "date_joined": gettext_lazy("Date joined"),
1053 "profile__translated": gettext_lazy("Translations made"),
1054 "profile__suggested": gettext_lazy("Suggestions made"),
1055 "profile__commented": gettext_lazy("Comments made"),
1056 "profile__uploaded": gettext_lazy("Screenshots uploaded"),
1057 }
1058 sort_values = set(sort_choices) | {f"-{val}" for val in sort_choices}
1060 def __init__(self, *args, **kwargs) -> None:
1061 super().__init__(*args, **kwargs)
1063 self.helper = FormHelper(self)
1064 self.helper.form_tag = False
1065 self.helper.disable_csrf = True
1066 self.helper.layout = Layout(
1067 Div(
1068 Field("q", template="snippets/user-query-field.html"),
1069 Field("sort_by", template="snippets/user-sort-field.html"),
1070 css_class="btn-toolbar",
1071 role="toolbar",
1072 ),
1073 )
1075 def clean_sort_by(self):
1076 sort_by = self.cleaned_data.get("sort_by")
1077 if sort_by:
1078 if sort_by not in self.sort_values:
1079 raise forms.ValidationError(
1080 gettext("The chosen sorting is not supported.")
1081 )
1082 return sort_by
1083 return None
1086class AdminUserSearchForm(UserSearchForm):
1087 q = QueryField(parser="superuser")
1090class GroupChoiceField(forms.ModelChoiceField):
1091 def label_from_instance(self, obj):
1092 return obj.long_name()
1095class GroupAddForm(forms.Form):
1096 add_group = GroupChoiceField(
1097 label=gettext_lazy("Add user to a team"),
1098 queryset=Group.objects.prefetch_related("defining_project").order(),
1099 required=True,
1100 )
1102 def __init__(self, *args, **kwargs) -> None:
1103 super().__init__(*args, **kwargs)
1104 self.helper = FormHelper(self)
1105 self.helper.form_class = "form-inline"
1106 self.helper.field_template = "bootstrap3/layout/inline_field.html"
1107 self.helper.layout = Layout(
1108 "add_group",
1109 Submit("add_group_button", gettext("Add team")),
1110 )
1113class GroupRemoveForm(forms.Form):
1114 remove_group = forms.ModelChoiceField(queryset=Group.objects.all(), required=True)
1117class TOTPDeviceForm(forms.Form):
1118 """Based on two_factor.forms.TOTPDeviceForm."""
1120 name = forms.CharField(
1121 # Must match django_otp.models.Device.name
1122 max_length=64,
1123 label=gettext_lazy("Name your authentication app"),
1124 )
1125 token = forms.IntegerField(
1126 label=gettext_lazy("Verify the code from the app"),
1127 min_value=0,
1128 max_value=999999,
1129 )
1131 token.widget.attrs.update(
1132 {
1133 "autofocus": "autofocus",
1134 "inputmode": "numeric",
1135 "autocomplete": "one-time-code",
1136 }
1137 )
1139 remove_previous = forms.BooleanField(
1140 required=False,
1141 initial=True,
1142 label=gettext_lazy("Discard previously configured authentication apps"),
1143 help_text=format_html(
1144 "{}<br>{}",
1145 gettext_lazy(
1146 "All previously configured authentication apps will be discarded upon verification of the new app."
1147 ),
1148 gettext(
1149 "Other two-factor methods (such as WebAuthn and security keys) won't be affected."
1150 ),
1151 ),
1152 )
1154 error_messages = { # noqa: RUF012
1155 "invalid_token": gettext_lazy("The entered token is not valid."),
1156 }
1158 def __init__(self, key, user, metadata=None, **kwargs) -> None:
1159 super().__init__(**kwargs)
1160 self.key = key
1161 self.tolerance = 1
1162 self.t0 = 0
1163 self.step = 30
1164 self.drift = 0
1165 self.digits = 6
1166 self.user = user
1167 self.metadata = metadata or {}
1168 if not self.user.totpdevice_set.exists():
1169 self.fields["remove_previous"].widget = forms.HiddenInput()
1171 @property
1172 def bin_key(self):
1173 """The secret key as a binary string."""
1174 return unhexlify(self.key.encode())
1176 def clean_token(self):
1177 token = self.cleaned_data.get("token")
1178 validated = False
1179 t0s = [self.t0]
1180 key = self.bin_key
1181 if "valid_t0" in self.metadata:
1182 t0s.append(int(time()) - self.metadata["valid_t0"])
1183 for t0 in t0s:
1184 for offset in range(-self.tolerance, self.tolerance + 1):
1185 if totp(key, self.step, t0, self.digits, self.drift + offset) == token:
1186 self.drift = offset
1187 self.metadata["valid_t0"] = int(time()) - t0
1188 validated = True
1189 if not validated:
1190 raise forms.ValidationError(self.error_messages["invalid_token"])
1191 return token
1193 def save(self):
1194 return TOTPDevice.objects.create(
1195 user=self.user,
1196 key=self.key,
1197 tolerance=self.tolerance,
1198 t0=self.t0,
1199 step=self.step,
1200 drift=self.drift,
1201 digits=self.digits,
1202 name=self.cleaned_data["name"],
1203 )
1206class WebAuthnTokenForm(forms.Form):
1207 show_submit = False
1209 def __init__(self, user, request=None, *args, **kwargs) -> None:
1210 super().__init__(*args, **kwargs)
1212 self.user = user
1213 self.request = request
1215 self.helper = FormHelper(self)
1216 self.helper.form_tag = False
1217 self.helper.layout = Layout(
1218 ContextDiv(template="accounts/webauthn.html", context={"request": request}),
1219 )
1222class OTPTokenForm(DjangoOTPTokenForm):
1223 show_submit = True
1224 otp_token = forms.CharField(
1225 label=gettext("Recovery token"),
1226 help_text=gettext(
1227 "Recovery codes can only be used once. Remember to mark used ones as expired."
1228 ),
1229 )
1230 device_class: type[Device] = StaticDevice
1232 def __init__(self, user, request=None, *args, **kwargs) -> None:
1233 super().__init__(user, request, *args, **kwargs)
1234 self.request = request
1235 self.fields["otp_device"].widget = forms.HiddenInput()
1236 self.fields["otp_device"].required = False
1237 self.fields["otp_challenge"].widget = forms.HiddenInput()
1238 self.fields["otp_token"].required = True
1239 self.fields["otp_token"].widget.attrs["autofocus"] = "autofocus"
1240 self.fields["otp_token"].widget.attrs["autocomplete"] = "off"
1242 self.helper = FormHelper(self)
1243 self.helper.form_tag = False
1245 def _chosen_device(self, user) -> None:
1246 return None
1248 @staticmethod
1249 def device_choices(user): # noqa: ARG004
1250 # Not needed as we do not support challenge/response devices
1251 # Also this is incompatible with WebAuthn
1252 return []
1254 def _verify_token(
1255 self, user: User, token: str, device: Device | None = None
1256 ) -> Device:
1257 if device is not None:
1258 return super()._verify_token(user, token, device)
1260 # We want to list only correct device classes, not all as django-otp does in match_token
1261 with transaction.atomic():
1262 device_set = self.device_class.objects.devices_for_user(
1263 user, confirmed=True
1264 )
1265 result = None
1266 for current_device in device_set.select_for_update():
1267 if current_device.verify_token(token):
1268 result = current_device
1269 break
1271 if result is None:
1272 otp_verification_failed.send(
1273 sender=self.__class__,
1274 user=user,
1275 )
1276 raise forms.ValidationError(
1277 self.otp_error_messages["invalid_token"], code="invalid_token"
1278 )
1279 return result
1282class TOTPTokenForm(OTPTokenForm):
1283 otp_token = forms.IntegerField(
1284 label=gettext_lazy("Enter the code from the app"),
1285 min_value=0,
1286 max_value=999999,
1287 )
1288 device_class: type[Device] = TOTPDevice
1290 def __init__(self, user, request=None, *args, **kwargs) -> None:
1291 super().__init__(user, request, *args, **kwargs)
1292 self.fields["otp_token"].widget.attrs.update(
1293 {
1294 "inputmode": "numeric",
1295 "autocomplete": "one-time-code",
1296 }
1297 )