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

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

2# 

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

4 

5from __future__ import annotations 

6 

7import base64 

8import json 

9from binascii import unhexlify 

10from datetime import datetime, timedelta 

11from time import time 

12from typing import TYPE_CHECKING, ClassVar, cast 

13 

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 

31 

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 

58 

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 

63 

64 from weblate.auth.models import AuthenticatedHttpRequest 

65 

66 

67class UniqueEmailMixin(forms.Form): 

68 validate_unique_mail = False 

69 

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"] 

95 

96 

97class PasswordField(forms.CharField): 

98 """Password field.""" 

99 

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) 

110 

111 

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 ) 

125 

126 return super().clean(value) 

127 

128 

129class FullNameField(forms.CharField): 

130 default_validators = [validate_fullname] # noqa: RUF012 

131 

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) 

140 

141 

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) 

148 

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) 

159 

160 

161class LanguagesForm(ProfileBaseForm): 

162 """User profile editing.""" 

163 

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 } 

172 

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" 

192 

193 def save(self, commit=True) -> None: 

194 super().save(commit=commit) 

195 # Activate selected language 

196 activate(self.cleaned_data["language"]) 

197 

198 

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 ) 

209 

210 class Meta: 

211 model = Profile 

212 fields = ("commit_email",) 

213 

214 def __init__(self, *args, **kwargs) -> None: 

215 super().__init__(*args, **kwargs) 

216 

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) 

224 

225 self.fields["commit_email"].choices += [(x, x) for x in sorted(commit_emails)] 

226 

227 self.helper = FormHelper(self) 

228 self.helper.disable_csrf = True 

229 self.helper.form_tag = False 

230 self.helper.template_pack = "bootstrap5" 

231 

232 

233class ProfileForm(ProfileBaseForm): 

234 """User profile editing.""" 

235 

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 ) 

241 

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 ) 

257 

258 def __init__(self, *args, **kwargs) -> None: 

259 super().__init__(*args, **kwargs) 

260 emails = get_all_user_mails(self.instance.user) 

261 

262 self.fields["public_email"].choices += [(x, x) for x in sorted(emails)] 

263 

264 self.helper = FormHelper(self) 

265 self.helper.disable_csrf = True 

266 self.helper.form_tag = False 

267 self.helper.template_pack = "bootstrap5" 

268 

269 

270class SubscriptionForm(ProfileBaseForm): 

271 """User watched projects management.""" 

272 

273 class Meta: 

274 model = Profile 

275 fields = ( 

276 "auto_watch", 

277 "watched", 

278 ) 

279 widgets = { # noqa: RUF012 

280 "watched": forms.SelectMultiple, 

281 } 

282 

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" 

298 

299 

300class UserSettingsForm(ProfileBaseForm): 

301 """User settings form.""" 

302 

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 ) 

317 

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" 

325 

326 

327class DashboardSettingsForm(ProfileBaseForm): 

328 """Dashboard settings form.""" 

329 

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 } 

337 

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 ) 

369 

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 

379 

380 

381class UserForm(forms.ModelForm): 

382 """User information form.""" 

383 

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 ) 

393 

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 } 

401 

402 def __init__(self, *args, **kwargs) -> None: 

403 super().__init__(*args, **kwargs) 

404 

405 emails = get_all_user_mails(self.instance) 

406 

407 self.fields["email"].choices = [(x, x) for x in sorted(emails)] 

408 self.fields["username"].valid = self.instance.username 

409 

410 self.helper = FormHelper(self) 

411 self.helper.disable_csrf = True 

412 self.helper.form_tag = False 

413 self.helper.template_pack = "bootstrap5" 

414 

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) 

420 

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 ) 

430 

431 

432class CaptchaWidget(forms.TextInput): 

433 challenge: Challenge | None = None 

434 

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) 

439 

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 ) 

466 

467 

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 ) 

473 

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 } 

482 

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 

494 

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 

504 

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

512 

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

519 

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 

525 

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 

533 

534 def generate_captcha(self) -> None: 

535 self.mathcaptcha = MathCaptcha() 

536 self.request.session["captcha"] = self.mathcaptcha.serialize() 

537 self.set_label() 

538 

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) 

551 

552 def store_challenge(self) -> None: 

553 self.request.session["captcha_challenge"] = self.challenge.challenge 

554 

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 ) 

566 

567 def clean_altcha(self) -> None: 

568 """Validate altcha.""" 

569 if not self.fields["altcha"].required: 

570 return 

571 payload = self.data.get("altcha", "") 

572 

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

578 

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

585 

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 

593 

594 def cleanup_session(self) -> None: 

595 self.request.session.pop("captcha", None) 

596 self.request.session.pop("captcha_challenge", None) 

597 

598 

599class ContactForm(CaptchaForm): 

600 """Form for contacting site owners.""" 

601 

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 ) 

619 

620 field_order = [ # noqa: RUF012 

621 "subject", 

622 "name", 

623 "email", 

624 "message", 

625 "captcha", 

626 "altcha", 

627 ] 

628 

629 

630class EmailForm(CaptchaForm, UniqueEmailMixin): 

631 """Email change form.""" 

632 

633 required_css_class = "required" 

634 error_css_class = "error" 

635 

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 ) 

640 

641 field_order = [ # noqa: RUF012 

642 "email", 

643 "captcha", 

644 "altcha", 

645 ] 

646 

647 

648class RegistrationForm(EmailForm): 

649 """Registration form.""" 

650 

651 required_css_class = "required" 

652 error_css_class = "error" 

653 

654 username = UniqueUsernameField() 

655 # This has to be without underscore for social-auth 

656 fullname = FullNameField() 

657 

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 ) 

677 

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 

696 

697 

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 ) 

708 

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

722 

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) 

726 

727 # Invalidate password reset codes 

728 invalidate_reset_codes(self.user) 

729 

730 if delete_session: 

731 request.session.flush() 

732 

733 messages.success(request, gettext("Your password has been changed.")) 

734 

735 

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) 

743 

744 

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 ) 

751 

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 ) 

764 

765 

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

772 

773 

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

777 

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 } 

784 

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) 

791 

792 def clean(self): 

793 username = self.cleaned_data.get("username") 

794 password = self.cleaned_data.get("password") 

795 

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 

841 

842 def get_user(self): 

843 return self.user_cache 

844 

845 

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 

854 

855 

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 ) 

866 

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 ) 

912 

913 @staticmethod 

914 def notification_fields(): 

915 for notification_cls in NOTIFICATIONS: 

916 yield (f"notify-{notification_cls.get_name()}", notification_cls) 

917 

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) 

921 

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 

929 

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 

936 

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) 

942 

943 @cached_property 

944 def form_scope(self): 

945 return int(self.get_form_param("scope", NotificationScope.SCOPE_WATCHED)) 

946 

947 @cached_property 

948 def form_project(self): 

949 return self.get_form_param("project", None) 

950 

951 @cached_property 

952 def form_component(self): 

953 return self.get_form_param("component", None) 

954 

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) 

966 

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} 

991 

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} 

1018 

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

1041 

1042 

1043class UserSearchForm(forms.Form): 

1044 """User searching form.""" 

1045 

1046 q = QueryField(parser="user") 

1047 sort_by = forms.CharField(required=False, widget=forms.HiddenInput) 

1048 

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} 

1059 

1060 def __init__(self, *args, **kwargs) -> None: 

1061 super().__init__(*args, **kwargs) 

1062 

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 ) 

1074 

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 

1084 

1085 

1086class AdminUserSearchForm(UserSearchForm): 

1087 q = QueryField(parser="superuser") 

1088 

1089 

1090class GroupChoiceField(forms.ModelChoiceField): 

1091 def label_from_instance(self, obj): 

1092 return obj.long_name() 

1093 

1094 

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 ) 

1101 

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 ) 

1111 

1112 

1113class GroupRemoveForm(forms.Form): 

1114 remove_group = forms.ModelChoiceField(queryset=Group.objects.all(), required=True) 

1115 

1116 

1117class TOTPDeviceForm(forms.Form): 

1118 """Based on two_factor.forms.TOTPDeviceForm.""" 

1119 

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 ) 

1130 

1131 token.widget.attrs.update( 

1132 { 

1133 "autofocus": "autofocus", 

1134 "inputmode": "numeric", 

1135 "autocomplete": "one-time-code", 

1136 } 

1137 ) 

1138 

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 ) 

1153 

1154 error_messages = { # noqa: RUF012 

1155 "invalid_token": gettext_lazy("The entered token is not valid."), 

1156 } 

1157 

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

1170 

1171 @property 

1172 def bin_key(self): 

1173 """The secret key as a binary string.""" 

1174 return unhexlify(self.key.encode()) 

1175 

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 

1192 

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 ) 

1204 

1205 

1206class WebAuthnTokenForm(forms.Form): 

1207 show_submit = False 

1208 

1209 def __init__(self, user, request=None, *args, **kwargs) -> None: 

1210 super().__init__(*args, **kwargs) 

1211 

1212 self.user = user 

1213 self.request = request 

1214 

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 ) 

1220 

1221 

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 

1231 

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" 

1241 

1242 self.helper = FormHelper(self) 

1243 self.helper.form_tag = False 

1244 

1245 def _chosen_device(self, user) -> None: 

1246 return None 

1247 

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 [] 

1253 

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) 

1259 

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 

1270 

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 

1280 

1281 

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 

1289 

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 )