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

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

2# 

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

4 

5from __future__ import annotations 

6 

7import os 

8from typing import TYPE_CHECKING, Literal 

9 

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 

20 

21from weblate.accounts.models import AuditLog, VerifiedEmail 

22from weblate.auth.models import User 

23from weblate.trans.signals import user_pre_delete 

24 

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 

27 

28 from weblate.accounts.types import DeviceType 

29 from weblate.auth.models import AuthenticatedHttpRequest 

30 

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" 

35 

36 

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

41 

42 # Store activity log and notify 

43 AuditLog.objects.create(user, request, "removed", **params) 

44 

45 # Remove any email validation codes 

46 invalidate_reset_codes(user) 

47 

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" 

55 

56 # Remove user information 

57 user.full_name = "Deleted User" 

58 

59 # Disable the user 

60 user.is_active = False 

61 user.set_unusable_password() 

62 user.save() 

63 

64 # Remove all social auth associations 

65 user.social_auth.all().delete() 

66 

67 # Remove user from all groups 

68 user.groups.clear() 

69 user.administered_group_set.clear() 

70 

71 # Remove user translation memory 

72 user.memory_set.all().delete() 

73 

74 # Clear subscriptions 

75 user.subscription_set.all().delete() 

76 user.profile.watched.clear() 

77 

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

95 

96 # Delete API tokens 

97 Token.objects.filter(user=user).delete() 

98 

99 

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) 

108 

109 

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 

131 

132 

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

138 

139 

140def cycle_session_keys(request: AuthenticatedHttpRequest, user: User) -> None: 

141 """ 

142 Cycle session keys. 

143 

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) 

152 

153 

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. 

162 

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 

170 

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 

177 

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) 

188 

189 

190def get_key_name(device: Device) -> str: 

191 # Prefer user provided name 

192 if device.name: 

193 return device.name 

194 

195 device_id: str | int = device.id 

196 device_label = f"{device.__class__.__name__} (%s)" 

197 

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 

203 

204 elif isinstance(device, TOTPDevice): 

205 device_label = gettext("Authentication app (%s)") 

206 

207 return device_label % device_id 

208 

209 

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) 

219 

220 

221class WeblateWebAuthnHelper(WebAuthnHelper): 

222 def register_complete(self, user: User, state: dict, data: dict): 

223 device = super().register_complete(user, state, data) 

224 

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 ) 

234 

235 # Store in session to possibly update after rename 

236 self.request.session[SESSION_WEBAUTHN_AUDIT] = audit.pk 

237 

238 return device