Coverage for app/venv/lib/python3.14/site-packages/weblate/accounts/utils.py: 19%
126 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 os
8from typing import TYPE_CHECKING, Literal
10from django.conf import settings
11from django.contrib.auth import update_session_auth_hash
12from django.core.exceptions import ObjectDoesNotExist
13from django.utils.translation import gettext
14from django_otp.plugins.otp_static.models import StaticDevice
15from django_otp.plugins.otp_totp.models import TOTPDevice
16from django_otp_webauthn.helpers import WebAuthnHelper
17from django_otp_webauthn.models import WebAuthnCredential
18from rest_framework.authtoken.models import Token
19from social_django.models import Code
21from weblate.accounts.models import AuditLog, VerifiedEmail
22from weblate.auth.models import User
23from weblate.trans.signals import user_pre_delete
25if TYPE_CHECKING: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 from django_otp.models import Device
28 from weblate.accounts.types import DeviceType
29 from weblate.auth.models import AuthenticatedHttpRequest
31SESSION_WEBAUTHN_AUDIT = "weblate:second_factor:webauthn_audit_log"
32SESSION_SECOND_FACTOR_USER = "weblate:second_factor:user"
33SESSION_SECOND_FACTOR_SOCIAL = "weblate:second_factor:social"
34SESSION_SECOND_FACTOR_TOTP = "weblate:second_factor:totp_key"
37def remove_user(user: User, request: AuthenticatedHttpRequest, **params) -> None:
38 """Remove user account."""
39 # Send signal (to commit any pending changes)
40 user_pre_delete.send(instance=user, sender=user.__class__)
42 # Store activity log and notify
43 AuditLog.objects.create(user, request, "removed", **params)
45 # Remove any email validation codes
46 invalidate_reset_codes(user)
48 # Change username
49 user.username = f"deleted-{user.pk}"
50 user.email = f"noreply+{user.pk}@weblate.org"
51 while User.objects.filter(username=user.username).exists():
52 user.username = f"deleted-{user.pk}-{os.urandom(5).hex()}"
53 while User.objects.filter(email=user.email).exists():
54 user.email = f"noreply+{user.pk}-{os.urandom(5).hex()}@weblate.org"
56 # Remove user information
57 user.full_name = "Deleted User"
59 # Disable the user
60 user.is_active = False
61 user.set_unusable_password()
62 user.save()
64 # Remove all social auth associations
65 user.social_auth.all().delete()
67 # Remove user from all groups
68 user.groups.clear()
69 user.administered_group_set.clear()
71 # Remove user translation memory
72 user.memory_set.all().delete()
74 # Clear subscriptions
75 user.subscription_set.all().delete()
76 user.profile.watched.clear()
78 # Cleanup profile
79 try:
80 profile = user.profile
81 except ObjectDoesNotExist:
82 pass
83 else:
84 profile.website = ""
85 profile.liberapay = ""
86 profile.fediverse = ""
87 profile.codesite = ""
88 profile.github = ""
89 profile.twitter = ""
90 profile.linkedin = ""
91 profile.location = ""
92 profile.company = ""
93 profile.public_email = ""
94 profile.save()
96 # Delete API tokens
97 Token.objects.filter(user=user).delete()
100def lock_user(
101 user: User,
102 reason: Literal["locked", "admin-locked"],
103 request: AuthenticatedHttpRequest | None = None,
104) -> None:
105 user.set_unusable_password()
106 user.save(update_fields=["password"])
107 AuditLog.objects.create(user, request, reason)
110def get_all_user_mails(user: User, entries=None, filter_deliverable=True):
111 """Return all verified mails for user."""
112 kwargs = {"social__user": user}
113 if entries:
114 kwargs["social__in"] = entries
115 if filter_deliverable:
116 # filter out emails that are not deliverable
117 emails = set(
118 VerifiedEmail.objects.filter(is_deliverable=True, **kwargs).values_list(
119 "email", flat=True
120 )
121 )
122 else:
123 # allow all emails, including non deliverable ones
124 emails = set(
125 VerifiedEmail.objects.filter(**kwargs).values_list("email", flat=True)
126 )
127 emails.add(user.email)
128 emails.discard(None) # type: ignore[arg-type]
129 emails.discard("")
130 return emails
133def invalidate_reset_codes(user=None, entries=None, emails=None) -> None:
134 """Invalidate email activation codes for an user."""
135 if emails is None:
136 emails = get_all_user_mails(user, entries)
137 Code.objects.filter(email__in=emails).delete()
140def cycle_session_keys(request: AuthenticatedHttpRequest, user: User) -> None:
141 """
142 Cycle session keys.
144 Updating the password logs out all other sessions for the user
145 except the current one and change key for current session.
146 """
147 # Change unusable password hash to be able to invalidate other sessions
148 if not user.has_usable_password():
149 user.set_unusable_password()
150 # Cycle session key
151 update_session_auth_hash(request, user)
154def adjust_session_expiry(
155 *,
156 request: AuthenticatedHttpRequest,
157 user: User,
158 is_login: bool = True,
159) -> None:
160 """
161 Adjust session expiry based on scope.
163 - Set longer expiry for authenticated users.
164 - Set short lived session for SAML authentication flow.
165 """
166 is_2fa = False
167 if user.profile.has_2fa and not user.is_verified():
168 # Still in second factor view
169 is_2fa = True
171 if "saml_only" not in request.session:
172 if is_login:
173 next_url = request.POST.get("next", request.GET.get("next"))
174 request.session["saml_only"] = next_url == "/idp/login/process/"
175 else:
176 request.session["saml_only"] = False
178 if request.session["saml_only"]:
179 # Short lived session for SAML authentication only
180 request.session.set_expiry(60)
181 elif is_2fa:
182 request.session.set_expiry(settings.SESSION_COOKIE_AGE_2FA)
183 elif is_login:
184 # Using default expiry for login flow
185 request.session.set_expiry(settings.SESSION_COOKIE_AGE)
186 else:
187 request.session.set_expiry(settings.SESSION_COOKIE_AGE_AUTHENTICATED)
190def get_key_name(device: Device) -> str:
191 # Prefer user provided name
192 if device.name:
193 return device.name
195 device_id: str | int = device.id
196 device_label = f"{device.__class__.__name__} (%s)"
198 if isinstance(device, WebAuthnCredential):
199 device_label = gettext("Security key (%s)")
200 # GUID is often masked by browser and zeroed one is useless
201 if device.aaguid != "00000000-0000-0000-0000-000000000000":
202 device_id = device.aaguid
204 elif isinstance(device, TOTPDevice):
205 device_label = gettext("Authentication app (%s)")
207 return device_label % device_id
210def get_key_type(device: Device) -> DeviceType:
211 if isinstance(device, WebAuthnCredential):
212 return "webauthn"
213 if isinstance(device, TOTPDevice):
214 return "totp"
215 if isinstance(device, StaticDevice):
216 return "recovery"
217 msg = f"Unsupported device: {device}"
218 raise TypeError(msg)
221class WeblateWebAuthnHelper(WebAuthnHelper):
222 def register_complete(self, user: User, state: dict, data: dict):
223 device = super().register_complete(user, state, data)
225 # Create audit log, but skip notification for now as
226 # the device name should be updated in the next request
227 audit = AuditLog.objects.create(
228 user,
229 self.request,
230 "twofactor-add",
231 skip_notify=True,
232 device=get_key_name(device),
233 )
235 # Store in session to possibly update after rename
236 self.request.session[SESSION_WEBAUTHN_AUDIT] = audit.pk
238 return device