Coverage for app/venv/lib/python3.14/site-packages/weblate/accounts/pipeline.py: 14%
270 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
4from __future__ import annotations
6import re
7import time
8import unicodedata
10from django.conf import settings
11from django.contrib.auth.models import AnonymousUser
12from django.http import HttpResponseRedirect
13from django.shortcuts import redirect
14from django.urls import reverse
15from django.utils.http import url_has_allowed_host_and_scheme, urlencode
16from django.utils.translation import gettext
17from django_otp import DEVICE_ID_SESSION_KEY
18from social_core.exceptions import AuthAlreadyAssociated, AuthMissingParameter
19from social_core.pipeline.partial import partial
20from social_core.utils import PARTIAL_TOKEN_SESSION_NAME
22from weblate.accounts.models import AuditLog, VerifiedEmail
23from weblate.accounts.notifications import send_notification_email
24from weblate.accounts.templatetags.authnames import get_auth_name
25from weblate.accounts.utils import (
26 SESSION_SECOND_FACTOR_SOCIAL,
27 SESSION_SECOND_FACTOR_USER,
28 adjust_session_expiry,
29 cycle_session_keys,
30 invalidate_reset_codes,
31)
32from weblate.auth.models import Invitation, User, get_anonymous
33from weblate.trans.defines import FULLNAME_LENGTH
34from weblate.utils import messages
35from weblate.utils.ratelimit import reset_rate_limit
36from weblate.utils.requests import request
37from weblate.utils.validators import (
38 CRUD_RE,
39 USERNAME_MATCHER,
40 EmailValidator,
41 clean_fullname,
42)
44STRIP_MATCHER = re.compile(r"[^\w\s.@+-]")
45CLEANUP_MATCHER = re.compile(r"[-\s]+")
48class UsernameAlreadyAssociated(AuthAlreadyAssociated):
49 pass
52class EmailAlreadyAssociated(AuthAlreadyAssociated):
53 pass
56def get_github_emails(access_token):
57 """Get real e-mail from GitHub."""
58 response = request(
59 "get",
60 "https://api.github.com/user/emails",
61 headers={"Authorization": f"token {access_token}"},
62 timeout=10.0,
63 )
64 data = response.json()
65 email = None
66 primary = None
67 public = None
68 emails = []
69 for entry in data:
70 # Skip noreply e-mail only if we need deliverable e-mails
71 if entry["email"].endswith("@users.noreply.github.com"):
72 # Add E-Mail and set is_deliverable to false
73 emails.append((entry["email"], False))
74 continue
75 # Skip not verified ones
76 if not entry["verified"]:
77 continue
79 # Add E-Mail and set is_deliverable to true
80 emails.append((entry["email"], True))
81 if entry.get("visibility") == "public":
82 # There is just one public mail, prefer it
83 public = entry["email"]
84 continue
85 email = entry["email"]
86 if entry["primary"]:
87 primary = entry["email"]
88 return public or primary or email, emails
91@partial
92def reauthenticate(
93 strategy, backend, user: User, social, uid, weblate_action, **kwargs
94):
95 """Force authentication when adding new association."""
96 session = strategy.request.session
97 if session.pop("reauthenticate_done", False):
98 return None
99 if weblate_action != "activation":
100 return None
101 if user and not social and user.has_usable_password():
102 session["reauthenticate"] = {
103 "backend": backend.name,
104 "backend_verbose": str(get_auth_name(backend.name)),
105 "uid": uid,
106 "user_pk": user.pk,
107 }
108 return redirect("confirm")
109 return None
112@partial
113def require_email(backend, details, weblate_action, user=None, is_new=False, **kwargs):
114 """Force entering e-mail for backends which don't provide it."""
115 if backend.name == "github":
116 email, emails = get_github_emails(kwargs["response"]["access_token"])
117 details["verified_emails"] = emails
118 if email is not None:
119 details["email"] = email
121 # Remove any pending e-mail validation codes
122 if details.get("email") and backend.name == "email":
123 invalidate_reset_codes(emails=(details["email"],))
124 # Remove all account reset codes
125 if user and weblate_action == "reset":
126 invalidate_reset_codes(user=user)
128 if user and user.email:
129 # Force validation of new e-mail address
130 if backend.name == "email":
131 return {"is_new": True}
133 return None
135 if is_new and not details.get("email"):
136 raise AuthMissingParameter(backend, "email")
137 return None
140def send_validation(strategy, backend, code, partial_token) -> None:
141 """Send verification e-mail."""
142 # We need to have existing session
143 session = strategy.request.session
144 if not session.session_key:
145 session.create()
146 session["registration-email-sent"] = True
148 url = "{}?verification_code={}&partial_token={}".format(
149 reverse("social:complete", args=(backend.name,)), code.code, partial_token
150 )
152 context = {"url": url, "validity": settings.AUTH_TOKEN_VALID // 3600}
154 template = "activation"
155 if session.get("password_reset"):
156 template = "reset"
157 elif session.get("account_remove"):
158 template = "remove"
160 # Use None for user to enable linking by e-mail later for the registration
161 AuditLog.objects.create(
162 strategy.request.user if strategy.request.user.is_authenticated else None,
163 strategy.request,
164 "sent-email",
165 email=code.email,
166 )
168 # Send actual confirmation
169 send_notification_email(None, [code.email], template, info=url, context=context)
172@partial
173def password_reset(
174 strategy,
175 backend,
176 user: User,
177 social,
178 details,
179 weblate_action,
180 current_partial,
181 **kwargs,
182):
183 """Set unusable password on reset."""
184 if strategy.request is not None and user is not None and weblate_action == "reset":
185 AuditLog.objects.create(
186 user,
187 strategy.request,
188 "reset",
189 method=backend.name,
190 name=social.uid,
191 password=user.password,
192 )
193 user.set_unusable_password()
194 user.save(update_fields=["password"])
195 # Remove partial pipeline, we do not need it
196 strategy.really_clean_partial_pipeline(current_partial.token)
197 session = strategy.request.session
198 # Store user ID
199 session["perform_reset"] = user.pk
200 # Redirect to form to change password
201 return redirect("password_reset")
202 return None
205@partial
206def remove_account(
207 strategy,
208 backend,
209 user: User,
210 social,
211 details,
212 weblate_action: str,
213 current_partial,
214 **kwargs,
215):
216 """Set unusable password on reset."""
217 if strategy.request is not None and user is not None and weblate_action == "remove":
218 # Remove partial pipeline, we do not need it
219 strategy.really_clean_partial_pipeline(current_partial.token)
220 # Set short session expiry
221 session = strategy.request.session
222 session["remove_confirm"] = True
223 # Reset rate limit to allow form submission
224 reset_rate_limit("remove", strategy.request)
225 # Redirect to the confirmation form
226 return redirect("remove")
227 return None
230def verify_open(
231 strategy,
232 backend,
233 user: User,
234 weblate_action: str,
235 invitation_link: Invitation | None,
236 **kwargs,
237) -> None:
238 """Check whether it is possible to create new user."""
239 # Ensure it's still same user (if sessions was kept as this is to avoid
240 # completing authentication under different user than initiated it, with
241 # new session, it will complete as new user)
242 current_user = strategy.request.user.pk
243 init_user = strategy.request.session.get("social_auth_user")
244 if strategy.request.session.session_key and current_user != init_user:
245 raise AuthMissingParameter(backend, "user")
247 # Check whether registration is open
248 if (
249 not user
250 and weblate_action not in {"reset", "remove"}
251 and not invitation_link
252 and (not settings.REGISTRATION_OPEN or settings.REGISTRATION_ALLOW_BACKENDS)
253 and backend.name not in settings.REGISTRATION_ALLOW_BACKENDS
254 ):
255 raise AuthMissingParameter(backend, "disabled")
258def cleanup_next(strategy, **kwargs):
259 # This is mostly fix for lack of next validation in Python Social Auth
260 # see https://github.com/python-social-auth/social-core/issues/62
261 url = strategy.session_get("next")
262 if url and not url_has_allowed_host_and_scheme(url, allowed_hosts=None):
263 strategy.session_set("next", None)
264 if url_has_allowed_host_and_scheme(kwargs.get("next", ""), allowed_hosts=None):
265 return None
266 return {"next": None}
269def store_params(strategy, user: User, **kwargs):
270 """Store Weblate specific parameters in the pipeline."""
271 # Map standard Django anonymuos user to Weblate database backed one
272 if isinstance(strategy.request.user, AnonymousUser):
273 strategy.request.user = get_anonymous()
274 # Registering user
275 registering_user = user.pk if user and user.is_authenticated else None
277 # Pipeline action
278 session = strategy.request.session
279 if session.get("password_reset"):
280 action = "reset"
281 elif session.get("account_remove"):
282 action = "remove"
283 else:
284 action = "activation"
286 invitation = None
287 if invitation_pk := session.get("invitation_link"):
288 try:
289 invitation = Invitation.objects.get(pk=invitation_pk)
290 except Invitation.DoesNotExist:
291 del session["invitation_link"]
292 invitation_pk = None
294 return {
295 "weblate_action": action,
296 "registering_user": registering_user,
297 "weblate_expires": int(time.time() + settings.AUTH_TOKEN_VALID),
298 "invitation_link": invitation,
299 "invitation_pk": str(invitation_pk) if invitation_pk else None,
300 }
303def verify_username(strategy, backend, details, username, user=None, **kwargs) -> None:
304 """
305 Verify whether username is still free.
307 It can happen that user has registered several times or other user has taken the
308 username meanwhile.
309 """
310 if user or not username:
311 return
312 if User.objects.filter(username=username).exists():
313 raise UsernameAlreadyAssociated(backend, "Username exists")
314 return
317def revoke_mail_code(strategy, details, **kwargs) -> None:
318 """
319 Remove old mail validation code for Python Social Auth.
321 PSA keeps them around, but we really don't need them again.
322 """
323 data = strategy.request_data()
324 if "email" in details and details["email"] and "verification_code" in data:
325 try:
326 code = strategy.storage.code.objects.get(
327 code=data["verification_code"], email=details["email"], verified=True
328 )
329 code.delete()
330 except strategy.storage.code.DoesNotExist:
331 return
334def ensure_valid(
335 strategy,
336 backend,
337 user: User,
338 registering_user,
339 weblate_action,
340 weblate_expires,
341 new_association,
342 details,
343 **kwargs,
344) -> None:
345 """Ensure the activation link is still."""
346 # Didn't the link expire?
347 if weblate_expires < time.time():
348 raise AuthMissingParameter(backend, "expires")
350 # We allow password reset for unauthenticated users
351 if weblate_action == "reset":
352 if strategy.request.user.is_authenticated:
353 messages.warning(
354 strategy.request,
355 gettext("You can not complete password reset while signed in."),
356 )
357 messages.warning(
358 strategy.request, gettext("The registration link has been invalidated.")
359 )
360 raise AuthMissingParameter(backend, "user")
361 return
363 # Add e-mail/register should stay on same user
364 current_user = user.pk if user and user.is_authenticated else None
366 if current_user != registering_user:
367 if registering_user is None:
368 messages.warning(
369 strategy.request,
370 gettext("You can not complete registration while signed in."),
371 )
372 else:
373 messages.warning(
374 strategy.request,
375 gettext("You can confirm your registration only while signed in."),
376 )
377 messages.warning(
378 strategy.request, gettext("The registration link has been invalidated.")
379 )
381 raise AuthMissingParameter(backend, "user")
383 # Verify if this mail is not used on other accounts
384 if new_association:
385 if "email" not in details:
386 raise AuthMissingParameter(backend, "email")
387 same = VerifiedEmail.objects.filter(email__iexact=details["email"])
388 if user:
389 same = same.exclude(social__user=user)
391 if not settings.REGISTRATION_REBIND and same.exists():
392 AuditLog.objects.create(same[0].social.user, strategy.request, "connect")
393 raise EmailAlreadyAssociated(backend, "E-mail exists")
395 validator = EmailValidator()
396 # This raises ValidationError
397 validator(details["email"])
400def store_email(strategy, backend, user: User, social, details, **kwargs) -> None:
401 """Store verified e-mail."""
402 # The email can be empty for some services
403 if details.get("verified_emails"):
404 # For some reasons tuples get converted to lists inside python social auth
405 current = {tuple(verified) for verified in details["verified_emails"]}
406 existing = set(social.verifiedemail_set.values_list("email", "is_deliverable"))
407 for remove in existing - current:
408 social.verifiedemail_set.filter(
409 email=remove[0], is_deliverable=remove[1]
410 ).delete()
411 for add in current - existing:
412 social.verifiedemail_set.create(email=add[0], is_deliverable=add[1])
413 elif details.get("email"):
414 verified, created = VerifiedEmail.objects.get_or_create(
415 social=social, defaults={"email": details["email"]}
416 )
417 if (
418 not created and verified.email != details["email"]
419 ) or not verified.is_deliverable:
420 verified.email = details["email"]
421 verified.is_deliverable = True
422 verified.save()
425def handle_invite(
426 strategy, backend, user: User, social, invitation_pk: str, **kwargs
427) -> None:
428 # Accept triggering invitation
429 if invitation_pk:
430 Invitation.objects.get(pk=invitation_pk).accept(strategy.request, user)
431 # Merge possibly pending invitations for this e-mail address
432 Invitation.objects.filter(email=user.email).update(user=user, email="")
435def notify_connect(
436 strategy,
437 details,
438 backend,
439 user: User,
440 social,
441 weblate_action,
442 new_association=False,
443 is_new=False,
444 **kwargs,
445) -> None:
446 """Notify about adding new link."""
447 # Adjust possibly pending email confirmation audit logs
448 AuditLog.objects.filter(
449 user=None,
450 activity="sent-email",
451 params={"email": details["email"]},
452 ).update(user=user)
453 if user and not is_new and weblate_action != "reset":
454 if new_association:
455 action = "auth-connect"
456 else:
457 action = "login"
458 adjust_session_expiry(request=strategy.request, user=user)
459 AuditLog.objects.create(
460 user,
461 strategy.request,
462 action,
463 method=backend.name,
464 name=social.uid,
465 )
466 # Remove partial pipeline
467 session = strategy.request.session
468 if PARTIAL_TOKEN_SESSION_NAME in session:
469 strategy.really_clean_partial_pipeline(session[PARTIAL_TOKEN_SESSION_NAME])
472def user_full_name(strategy, details, username, user=None, **kwargs) -> None:
473 """Update user full name using data from provider."""
474 if user and not user.full_name:
475 full_name = details.get("fullname") or ""
476 full_name = full_name.strip()
478 if not full_name and ("first_name" in details or "last_name" in details):
479 first_name = details.get("first_name") or ""
480 last_name = details.get("last_name") or ""
482 if first_name and first_name not in last_name:
483 full_name = f"{first_name} {last_name}"
484 elif first_name:
485 full_name = first_name
486 else:
487 full_name = last_name
489 if CRUD_RE.match(full_name):
490 full_name = ""
492 if not full_name and username:
493 full_name = username
495 if not full_name and user.username:
496 full_name = user.username
498 full_name = clean_fullname(full_name)
500 # The User model limit is 150 chars
501 if len(full_name) > FULLNAME_LENGTH:
502 full_name = full_name[:FULLNAME_LENGTH]
504 if full_name:
505 user.full_name = full_name
506 strategy.storage.user.changed(user)
509def slugify_username(value):
510 """
511 Clean up username.
513 This is based on Django slugify with exception of lowercasing
515 - Converts to ascii
516 - Removes not wanted chars
517 - Merges whitespaces and - into single -
518 """
519 value = (
520 unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
521 )
523 # Return username if it matches our standards
524 if USERNAME_MATCHER.match(value):
525 return value
527 value = STRIP_MATCHER.sub("", value).strip().lstrip(".")
528 return CLEANUP_MATCHER.sub("-", value)
531def cycle_session(strategy, user: User, *args, **kwargs) -> None:
532 # Change key for current session and invalidate others
533 cycle_session_keys(strategy.request, user)
536def adjust_primary_mail(strategy, entries, user: User, *args, **kwargs) -> None:
537 """Fix primary mail on disconnect."""
538 # Remove pending verification codes
539 invalidate_reset_codes(user=user, entries=entries)
541 # Check remaining verified mails
542 verified = VerifiedEmail.objects.filter(social__user=user).exclude(
543 social__in=entries
544 )
545 if verified.filter(email=user.email).exists():
546 return
548 user.email = verified[0].email
549 user.save()
550 messages.warning(
551 strategy.request,
552 gettext(
553 "Your e-mail no longer belongs to verified account, "
554 "it has been changed to {0}."
555 ).format(user.email),
556 )
559def notify_disconnect(strategy, backend, entries, user: User, **kwargs) -> None:
560 """Store verified e-mail."""
561 for social in entries:
562 AuditLog.objects.create(
563 user,
564 strategy.request,
565 "auth-disconnect",
566 method=backend.name,
567 name=social.uid,
568 )
571@partial
572def second_factor(strategy, backend, user: User, current_partial, **kwargs):
573 """Force authentication when adding new association."""
574 if user.profile.has_2fa and DEVICE_ID_SESSION_KEY not in strategy.request.session:
575 # Store session indication for second factor
576 strategy.request.session[SESSION_SECOND_FACTOR_USER] = (user.id, "")
577 strategy.request.session[SESSION_SECOND_FACTOR_SOCIAL] = True
578 # Redirect to second factor login
579 continue_url = "{}?partial_token={}".format(
580 reverse("social:complete", args=(backend.name,)), current_partial.token
581 )
582 login_params = {"next": continue_url}
583 login_url = reverse(
584 "2fa-login", kwargs={"backend": user.profile.get_second_factor_type()}
585 )
586 return HttpResponseRedirect(f"{login_url}?{urlencode(login_params)}")
587 return None