django-adminflow 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adminflow/__init__.py +1 -0
- adminflow/apps.py +22 -0
- adminflow/context_processors.py +72 -0
- adminflow/forms.py +89 -0
- adminflow/import_export.py +224 -0
- adminflow/static/adminflow/img/logo.png +0 -0
- adminflow/templates/admin/actions.html +79 -0
- adminflow/templates/admin/base.html +1263 -0
- adminflow/templates/admin/base_site.html +5 -0
- adminflow/templates/admin/change_form.html +315 -0
- adminflow/templates/admin/change_list.html +216 -0
- adminflow/templates/admin/change_list_results.html +211 -0
- adminflow/templates/admin/delete_confirmation.html +55 -0
- adminflow/templates/admin/delete_selected_confirmation.html +118 -0
- adminflow/templates/admin/edit_inline/stacked.html +85 -0
- adminflow/templates/admin/edit_inline/tabular.html +81 -0
- adminflow/templates/admin/filter.html +17 -0
- adminflow/templates/admin/import_export/change_list_export_item.html +8 -0
- adminflow/templates/admin/import_export/change_list_import_item.html +8 -0
- adminflow/templates/admin/import_export/export.html +199 -0
- adminflow/templates/admin/includes/fieldset.html +73 -0
- adminflow/templates/admin/includes/toast.html +102 -0
- adminflow/templates/admin/index.html +227 -0
- adminflow/templates/admin/logged_out.html +38 -0
- adminflow/templates/admin/login.html +144 -0
- adminflow/templates/admin/object_history.html +60 -0
- adminflow/templates/admin/pagination.html +30 -0
- adminflow/templates/admin/search_form.html +28 -0
- adminflow/templates/admin/submit_line.html +48 -0
- adminflow/templates/admin/verify_2fa.html +280 -0
- adminflow/templates/otp/email/token.html +64 -0
- adminflow/templates/otp/email/token.txt +11 -0
- adminflow/templates/registration/logged_out.html +38 -0
- adminflow/templates/registration/password_change_form.html +272 -0
- adminflow/templates/simple_history/object_history.html +186 -0
- adminflow/templates/simple_history/object_history_form.html +103 -0
- adminflow/templates/simple_history/submit_line.html +17 -0
- adminflow/templatetags/__init__.py +1 -0
- adminflow/templatetags/__pycache__/__init__.cpython-314.pyc +0 -0
- adminflow/templatetags/__pycache__/adminflow_tags.cpython-314.pyc +0 -0
- adminflow/templatetags/adminflow_tags.py +233 -0
- adminflow/user_admin.py +662 -0
- adminflow/views.py +207 -0
- django_adminflow-1.0.0.dist-info/METADATA +398 -0
- django_adminflow-1.0.0.dist-info/RECORD +47 -0
- django_adminflow-1.0.0.dist-info/WHEEL +5 -0
- django_adminflow-1.0.0.dist-info/top_level.txt +1 -0
adminflow/user_admin.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
"""
|
|
2
|
+
adminflow.user_admin
|
|
3
|
+
====================
|
|
4
|
+
Plug-and-play 2FA admin helpers for Django admin.
|
|
5
|
+
|
|
6
|
+
Usage in your admin.py::
|
|
7
|
+
|
|
8
|
+
from adminflow.user_admin import AdminFlowUserAdminMixin, get_2fa_inlines
|
|
9
|
+
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
|
10
|
+
|
|
11
|
+
@admin.register(User)
|
|
12
|
+
class UserAdmin(AdminFlowUserAdminMixin, BaseUserAdmin):
|
|
13
|
+
inlines = (YourCustomInline,) + get_2fa_inlines()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from django import forms
|
|
22
|
+
from django.conf import settings
|
|
23
|
+
from django.contrib import admin
|
|
24
|
+
from django.contrib.auth import get_user_model
|
|
25
|
+
from django.core.exceptions import ValidationError
|
|
26
|
+
from django.utils.html import format_html
|
|
27
|
+
from django.utils.safestring import mark_safe
|
|
28
|
+
|
|
29
|
+
User = get_user_model()
|
|
30
|
+
|
|
31
|
+
HAS_OTP_TOTP = "django_otp.plugins.otp_totp" in settings.INSTALLED_APPS
|
|
32
|
+
HAS_OTP_EMAIL = "django_otp.plugins.otp_email" in settings.INSTALLED_APPS
|
|
33
|
+
HAS_OTP_STATIC = "django_otp.plugins.otp_static" in settings.INSTALLED_APPS
|
|
34
|
+
|
|
35
|
+
# ─────────────────────────────────────────────
|
|
36
|
+
# TOTP (Authenticator App) Inline
|
|
37
|
+
# ─────────────────────────────────────────────
|
|
38
|
+
if HAS_OTP_TOTP:
|
|
39
|
+
from django_otp.plugins.otp_totp.models import TOTPDevice, default_key
|
|
40
|
+
import qrcode
|
|
41
|
+
|
|
42
|
+
AUTHENTICATOR_APP_CHOICES = [
|
|
43
|
+
("", "— Select Authenticator App —"),
|
|
44
|
+
("Google Authenticator", "Google Authenticator"),
|
|
45
|
+
("1Password", "1Password"),
|
|
46
|
+
("Authy", "Authy"),
|
|
47
|
+
("Microsoft Authenticator", "Microsoft Authenticator"),
|
|
48
|
+
("Bitwarden", "Bitwarden"),
|
|
49
|
+
("Duo Mobile", "Duo Mobile"),
|
|
50
|
+
("Aegis Authenticator", "Aegis Authenticator"),
|
|
51
|
+
("Raivo OTP", "Raivo OTP"),
|
|
52
|
+
("Keychain (iOS/macOS)", "Keychain (iOS/macOS)"),
|
|
53
|
+
("Other", "Other"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
class TOTPDeviceForm(forms.ModelForm):
|
|
57
|
+
key = forms.CharField(widget=forms.HiddenInput(), required=False)
|
|
58
|
+
name = forms.ChoiceField(
|
|
59
|
+
choices=AUTHENTICATOR_APP_CHOICES,
|
|
60
|
+
label="Authenticator App",
|
|
61
|
+
help_text="Select which authenticator app you are using. Each app must be unique per account.",
|
|
62
|
+
widget=forms.Select(attrs={"style": "width:100%; max-width:320px;"}),
|
|
63
|
+
)
|
|
64
|
+
verification_code = forms.CharField(
|
|
65
|
+
required=False, max_length=6, min_length=6,
|
|
66
|
+
label="6-Digit Verification Code (from Mobile App)",
|
|
67
|
+
widget=forms.TextInput(attrs={
|
|
68
|
+
"placeholder": "123456",
|
|
69
|
+
"style": "letter-spacing:2px;font-family:monospace;font-weight:bold;width:180px;text-align:center;font-size:14px;",
|
|
70
|
+
"autocomplete": "off",
|
|
71
|
+
}),
|
|
72
|
+
help_text="Scan the QR code above with your authenticator app, enter the 6-digit code, then click Save.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
class Meta:
|
|
76
|
+
model = TOTPDevice
|
|
77
|
+
fields = ("name", "key", "confirmed")
|
|
78
|
+
|
|
79
|
+
def _get_validation_exclusions(self):
|
|
80
|
+
exclusions = super()._get_validation_exclusions()
|
|
81
|
+
for f in ("name","key","step","t0","digits","tolerance","drift","last_t",
|
|
82
|
+
"throttling_failure_count","throttling_failure_timestamp"):
|
|
83
|
+
exclusions.add(f)
|
|
84
|
+
return exclusions
|
|
85
|
+
|
|
86
|
+
def __init__(self, *args, **kwargs):
|
|
87
|
+
super().__init__(*args, **kwargs)
|
|
88
|
+
if not self.instance.pk:
|
|
89
|
+
k = getattr(self.instance, "key", None) or self.initial.get("key") or default_key()
|
|
90
|
+
self.instance.key = k
|
|
91
|
+
self.initial["key"] = k
|
|
92
|
+
if "key" in self.fields:
|
|
93
|
+
self.fields["key"].initial = k
|
|
94
|
+
|
|
95
|
+
if "name" in self.fields:
|
|
96
|
+
user = getattr(self.instance, "user", None)
|
|
97
|
+
used_names = set()
|
|
98
|
+
if user and user.pk:
|
|
99
|
+
qs = TOTPDevice.objects.filter(user=user)
|
|
100
|
+
if self.instance.pk:
|
|
101
|
+
qs = qs.exclude(pk=self.instance.pk)
|
|
102
|
+
used_names = set(qs.values_list("name", flat=True))
|
|
103
|
+
available = [
|
|
104
|
+
(val, label) for val, label in AUTHENTICATOR_APP_CHOICES
|
|
105
|
+
if val == "" or val not in used_names
|
|
106
|
+
]
|
|
107
|
+
if self.instance.pk and self.instance.name:
|
|
108
|
+
existing = (self.instance.name, self.instance.name)
|
|
109
|
+
if existing not in available:
|
|
110
|
+
available.insert(1, existing)
|
|
111
|
+
self.fields["name"].choices = available
|
|
112
|
+
|
|
113
|
+
def has_changed(self):
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def clean(self):
|
|
117
|
+
cleaned_data = super().clean()
|
|
118
|
+
code = cleaned_data.get("verification_code", "").strip()
|
|
119
|
+
submitted_key = (
|
|
120
|
+
self.data.get(self.add_prefix("key"))
|
|
121
|
+
or cleaned_data.get("key")
|
|
122
|
+
or self.initial.get("key")
|
|
123
|
+
)
|
|
124
|
+
device_name = cleaned_data.get("name", "").strip()
|
|
125
|
+
|
|
126
|
+
if submitted_key and not self.instance.pk:
|
|
127
|
+
self.instance.key = submitted_key
|
|
128
|
+
|
|
129
|
+
user = getattr(self.instance, "user", None) or cleaned_data.get("user")
|
|
130
|
+
if not user:
|
|
131
|
+
user_id = self.data.get(self.add_prefix("user"))
|
|
132
|
+
if user_id:
|
|
133
|
+
user = User.objects.filter(pk=user_id).first()
|
|
134
|
+
if user:
|
|
135
|
+
self.instance.user = user
|
|
136
|
+
|
|
137
|
+
if device_name and user and user.pk:
|
|
138
|
+
qs = TOTPDevice.objects.filter(user=user, name=device_name)
|
|
139
|
+
if self.instance.pk:
|
|
140
|
+
qs = qs.exclude(pk=self.instance.pk)
|
|
141
|
+
if qs.exists():
|
|
142
|
+
raise ValidationError({
|
|
143
|
+
"name": f"A 2FA device with '{device_name}' already exists for this account. "
|
|
144
|
+
"Please select a different authenticator app."
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
cleaned_data["confirmed"] = False
|
|
148
|
+
self.instance.confirmed = False
|
|
149
|
+
self._is_verified = False
|
|
150
|
+
|
|
151
|
+
if not code:
|
|
152
|
+
raise ValidationError({
|
|
153
|
+
"verification_code": "Verification Code Required! Enter the 6-digit code from your authenticator app and click Save."
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if hasattr(self.instance, "verify_token") and getattr(self.instance, "bin_key", None):
|
|
157
|
+
self.instance.tolerance = 2
|
|
158
|
+
if not self.instance.verify_token(code):
|
|
159
|
+
cleaned_data["confirmed"] = False
|
|
160
|
+
self.instance.confirmed = False
|
|
161
|
+
if self.instance.pk:
|
|
162
|
+
try:
|
|
163
|
+
self.instance.delete()
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
raise ValidationError({
|
|
167
|
+
"verification_code": "Verification Failed! The code is invalid or expired. Re-scan the QR code and try again."
|
|
168
|
+
})
|
|
169
|
+
cleaned_data["confirmed"] = True
|
|
170
|
+
self.instance.confirmed = True
|
|
171
|
+
self._is_verified = True
|
|
172
|
+
else:
|
|
173
|
+
if self.instance.pk:
|
|
174
|
+
try:
|
|
175
|
+
self.instance.delete()
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
raise ValidationError({"verification_code": "Verification Failed! Secret key is missing."})
|
|
179
|
+
|
|
180
|
+
return cleaned_data
|
|
181
|
+
|
|
182
|
+
def save(self, commit=True):
|
|
183
|
+
instance = super().save(commit=False)
|
|
184
|
+
if getattr(self, "_is_verified", False):
|
|
185
|
+
instance.confirmed = True
|
|
186
|
+
if commit:
|
|
187
|
+
instance.save()
|
|
188
|
+
return instance
|
|
189
|
+
|
|
190
|
+
class TOTPDeviceInline(admin.StackedInline):
|
|
191
|
+
model = TOTPDevice
|
|
192
|
+
form = TOTPDeviceForm
|
|
193
|
+
extra = 0
|
|
194
|
+
max_num = 5
|
|
195
|
+
fields = ("name", "key", "get_secret_key", "get_qrcode", "verification_code", "verification_status", "confirmed")
|
|
196
|
+
readonly_fields = ("get_secret_key", "get_qrcode", "verification_status")
|
|
197
|
+
verbose_name = "2FA Authenticator Device"
|
|
198
|
+
verbose_name_plural = "2FA Authenticator Devices (OTP)"
|
|
199
|
+
|
|
200
|
+
def get_formset(self, request, obj=None, **kwargs):
|
|
201
|
+
self._parent_user = obj
|
|
202
|
+
return super().get_formset(request, obj, **kwargs)
|
|
203
|
+
|
|
204
|
+
def get_secret_key(self, obj):
|
|
205
|
+
if not obj:
|
|
206
|
+
return mark_safe("<span style='color:#64748b;font-size:12px;'>Secret key generated on first save.</span>")
|
|
207
|
+
if not getattr(obj, "bin_key", None) and not getattr(obj, "key", None):
|
|
208
|
+
obj.key = default_key()
|
|
209
|
+
try:
|
|
210
|
+
b32 = base64.b32encode(obj.bin_key).decode("utf-8")
|
|
211
|
+
return format_html(
|
|
212
|
+
"<div><code style='font-size:14px;font-weight:bold;letter-spacing:2px;background:#f1f5f9;"
|
|
213
|
+
"padding:6px 10px;border-radius:8px;font-family:monospace;color:#0f172a;border:1px solid #cbd5e1;'>{}</code>"
|
|
214
|
+
"<span style='font-size:11px;color:#64748b;margin-left:10px;'>(Base32 — for Google Authenticator / 1Password)</span></div>",
|
|
215
|
+
b32,
|
|
216
|
+
)
|
|
217
|
+
except Exception:
|
|
218
|
+
return "-"
|
|
219
|
+
get_secret_key.short_description = "Secret Key (Base32)"
|
|
220
|
+
|
|
221
|
+
def get_qrcode(self, obj):
|
|
222
|
+
if not obj:
|
|
223
|
+
return mark_safe("<span style='color:#64748b;font-size:12px;'>QR Code generated on first save.</span>")
|
|
224
|
+
if not getattr(obj, "bin_key", None) and not getattr(obj, "key", None):
|
|
225
|
+
obj.key = default_key()
|
|
226
|
+
try:
|
|
227
|
+
b32 = base64.b32encode(obj.bin_key).decode("utf-8")
|
|
228
|
+
user = getattr(obj, "user", None) or getattr(self, "_parent_user", None)
|
|
229
|
+
email = getattr(user, "email", None) if user else None
|
|
230
|
+
username = user.get_username() if (user and hasattr(user, "get_username")) else None
|
|
231
|
+
account_title = email or username or "User"
|
|
232
|
+
app_title = getattr(settings, "ADMINFLOW_TITLE", getattr(settings, "ADMINFLOW_COMPANY", "AdminFlow"))
|
|
233
|
+
config_url = f"otpauth://totp/{app_title}:{account_title}?secret={b32}&issuer={app_title}"
|
|
234
|
+
img = qrcode.make(config_url)
|
|
235
|
+
buf = io.BytesIO()
|
|
236
|
+
img.save(buf, format="PNG")
|
|
237
|
+
data = base64.b64encode(buf.getvalue()).decode("utf-8")
|
|
238
|
+
return format_html(
|
|
239
|
+
"<div style='display:flex;align-items:center;gap:16px;margin-top:6px;'>"
|
|
240
|
+
"<img src='data:image/png;base64,{}' style='width:140px;height:140px;border-radius:12px;border:1px solid #cbd5e1;padding:6px;background:white;'/>"
|
|
241
|
+
"<div><p style='font-weight:700;font-size:13px;color:#0f172a;margin:0 0 2px;'>Scan with Authenticator App</p>"
|
|
242
|
+
"<p style='font-size:11px;color:#374151;font-family:monospace;background:#f1f5f9;display:inline-block;padding:2px 8px;border-radius:4px;margin:0 0 6px;'>{}: {}</p>"
|
|
243
|
+
"<p style='font-size:11px;color:#64748b;margin:0;max-width:260px;line-height:1.4;'>Scan this QR code, enter the 6-digit code below, then click Save to verify.</p>"
|
|
244
|
+
"</div></div>",
|
|
245
|
+
data, app_title, account_title,
|
|
246
|
+
)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
return format_html("<span style='color:#ef4444;font-size:11px;'>Error generating QR code: {}</span>", str(e))
|
|
249
|
+
get_qrcode.short_description = "QR Code (Scan for Authenticator App)"
|
|
250
|
+
|
|
251
|
+
def verification_status(self, obj):
|
|
252
|
+
if not obj or not getattr(obj, "bin_key", None):
|
|
253
|
+
return mark_safe("<span style='color:#64748b;font-size:12px;'>Save device to view status.</span>")
|
|
254
|
+
if obj.confirmed:
|
|
255
|
+
return mark_safe(
|
|
256
|
+
"<div style='display:inline-flex;align-items:center;gap:6px;background:#f0fdf4;"
|
|
257
|
+
"border:1px solid #bbf7d0;color:#166534;font-weight:700;font-size:12px;"
|
|
258
|
+
"padding:6px 14px;border-radius:8px;'>✓ 2FA Authenticator Device Verified & Active</div>"
|
|
259
|
+
)
|
|
260
|
+
return mark_safe(
|
|
261
|
+
"<div style='background:#fffbeb;border:1px solid #fde68a;padding:10px 14px;border-radius:8px;margin-top:4px;'>"
|
|
262
|
+
"<p style='font-weight:700;font-size:12px;color:#b45309;margin:0 0 2px;'>⚠️ Security Verification Pending</p>"
|
|
263
|
+
"<p style='font-size:11px;color:#78350f;margin:0;'>Enter your 6-digit code above and click <b>Save</b> to verify & activate 2FA.</p>"
|
|
264
|
+
"</div>"
|
|
265
|
+
)
|
|
266
|
+
verification_status.short_description = "Security Verification"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ─────────────────────────────────────────────
|
|
270
|
+
# Email OTP Inline
|
|
271
|
+
# ─────────────────────────────────────────────
|
|
272
|
+
if HAS_OTP_EMAIL:
|
|
273
|
+
from django_otp.plugins.otp_email.models import EmailDevice
|
|
274
|
+
|
|
275
|
+
class EmailDeviceForm(forms.ModelForm):
|
|
276
|
+
class Meta:
|
|
277
|
+
model = EmailDevice
|
|
278
|
+
fields = ("confirmed",)
|
|
279
|
+
|
|
280
|
+
def _get_validation_exclusions(self):
|
|
281
|
+
exclusions = super()._get_validation_exclusions()
|
|
282
|
+
exclusions.add("name")
|
|
283
|
+
exclusions.add("email")
|
|
284
|
+
return exclusions
|
|
285
|
+
|
|
286
|
+
def has_changed(self):
|
|
287
|
+
return True
|
|
288
|
+
|
|
289
|
+
def clean(self):
|
|
290
|
+
cleaned_data = super().clean()
|
|
291
|
+
if self.instance and hasattr(self.instance, "user") and self.instance.user:
|
|
292
|
+
self.instance.email = (
|
|
293
|
+
self.instance.user.email
|
|
294
|
+
or f"{self.instance.user.username}@example.com"
|
|
295
|
+
)
|
|
296
|
+
self.instance.name = "default"
|
|
297
|
+
return cleaned_data
|
|
298
|
+
|
|
299
|
+
class EmailDeviceInline(admin.StackedInline):
|
|
300
|
+
model = EmailDevice
|
|
301
|
+
form = EmailDeviceForm
|
|
302
|
+
extra = 0
|
|
303
|
+
max_num = 1
|
|
304
|
+
fields = ("confirmed",)
|
|
305
|
+
verbose_name = "Email OTP 2FA Device"
|
|
306
|
+
verbose_name_plural = "Email OTP 2FA Devices (Email Verification Code)"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ─────────────────────────────────────────────
|
|
310
|
+
# Static / Backup Codes Inline
|
|
311
|
+
# ─────────────────────────────────────────────
|
|
312
|
+
if HAS_OTP_STATIC:
|
|
313
|
+
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
|
|
314
|
+
|
|
315
|
+
def _get_backup_codes_file():
|
|
316
|
+
base_dir = getattr(settings, "BASE_DIR", ".")
|
|
317
|
+
return os.path.join(str(base_dir), ".adminflow_backup_codes.json")
|
|
318
|
+
|
|
319
|
+
def _load_batch_tokens(device_id):
|
|
320
|
+
filepath = _get_backup_codes_file()
|
|
321
|
+
if os.path.exists(filepath):
|
|
322
|
+
try:
|
|
323
|
+
with open(filepath, "r") as f:
|
|
324
|
+
return json.load(f).get(str(device_id), [])
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
return []
|
|
328
|
+
|
|
329
|
+
def _save_batch_tokens(device_id, tokens):
|
|
330
|
+
filepath = _get_backup_codes_file()
|
|
331
|
+
data = {}
|
|
332
|
+
if os.path.exists(filepath):
|
|
333
|
+
try:
|
|
334
|
+
with open(filepath, "r") as f:
|
|
335
|
+
data = json.load(f)
|
|
336
|
+
except Exception:
|
|
337
|
+
data = {}
|
|
338
|
+
data[str(device_id)] = tokens
|
|
339
|
+
try:
|
|
340
|
+
with open(filepath, "w") as f:
|
|
341
|
+
json.dump(data, f)
|
|
342
|
+
except Exception:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
class StaticDeviceForm(forms.ModelForm):
|
|
346
|
+
class Meta:
|
|
347
|
+
model = StaticDevice
|
|
348
|
+
fields = ("confirmed",)
|
|
349
|
+
|
|
350
|
+
def has_changed(self):
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
def clean(self):
|
|
354
|
+
cleaned_data = super().clean()
|
|
355
|
+
self.instance.name = "backup_codes"
|
|
356
|
+
self.instance.confirmed = True
|
|
357
|
+
return cleaned_data
|
|
358
|
+
|
|
359
|
+
class StaticDeviceInline(admin.StackedInline):
|
|
360
|
+
model = StaticDevice
|
|
361
|
+
form = StaticDeviceForm
|
|
362
|
+
extra = 0
|
|
363
|
+
max_num = 1
|
|
364
|
+
fields = ("confirmed", "get_backup_codes")
|
|
365
|
+
readonly_fields = ("get_backup_codes",)
|
|
366
|
+
verbose_name = "10 Emergency Recovery Backup Codes"
|
|
367
|
+
verbose_name_plural = "10 Emergency Recovery Backup Codes (Static OTP)"
|
|
368
|
+
|
|
369
|
+
def get_backup_codes(self, obj):
|
|
370
|
+
if not obj or not getattr(obj, "pk", None):
|
|
371
|
+
return mark_safe(
|
|
372
|
+
"<span style='color:#64748b;font-size:12px;'>Click <b>Save</b> to generate 10 Emergency Recovery Backup Codes.</span>"
|
|
373
|
+
)
|
|
374
|
+
active_tokens = list(StaticToken.objects.filter(device=obj).values_list("token", flat=True))
|
|
375
|
+
batch_tokens = _load_batch_tokens(obj.pk)
|
|
376
|
+
if not active_tokens:
|
|
377
|
+
active_tokens = []
|
|
378
|
+
for _ in range(10):
|
|
379
|
+
t = StaticToken.random_token()
|
|
380
|
+
StaticToken.objects.create(device=obj, token=t)
|
|
381
|
+
active_tokens.append(t)
|
|
382
|
+
batch_tokens = list(active_tokens)
|
|
383
|
+
_save_batch_tokens(obj.pk, batch_tokens)
|
|
384
|
+
if not obj.confirmed:
|
|
385
|
+
obj.confirmed = True
|
|
386
|
+
obj.save()
|
|
387
|
+
elif not batch_tokens:
|
|
388
|
+
batch_tokens = list(active_tokens)
|
|
389
|
+
_save_batch_tokens(obj.pk, batch_tokens)
|
|
390
|
+
|
|
391
|
+
display = batch_tokens if batch_tokens else active_tokens
|
|
392
|
+
lis = []
|
|
393
|
+
for t in display:
|
|
394
|
+
if t in active_tokens:
|
|
395
|
+
lis.append(
|
|
396
|
+
f"<li style='font-family:monospace;font-weight:bold;font-size:13px;background:#f1f5f9;"
|
|
397
|
+
f"padding:6px 10px;border-radius:8px;border:1px solid #cbd5e1;text-align:center;"
|
|
398
|
+
f"color:#0f172a;display:inline-flex;align-items:center;justify-content:center;' title='Active'>{t}</li>"
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
lis.append(
|
|
402
|
+
f"<li style='font-family:monospace;font-weight:bold;font-size:13px;background:#fef2f2;"
|
|
403
|
+
f"padding:6px 10px;border-radius:8px;border:1px dashed #fca5a5;text-align:center;"
|
|
404
|
+
f"color:#991b1b;text-decoration:line-through;opacity:0.65;display:inline-flex;"
|
|
405
|
+
f"align-items:center;justify-content:center;gap:4px;' title='Used'>"
|
|
406
|
+
f"<span>{t}</span>"
|
|
407
|
+
f"<span style='font-size:9px;font-family:sans-serif;text-decoration:none;background:#ef4444;"
|
|
408
|
+
f"color:white;padding:1px 4px;border-radius:4px;font-weight:bold;line-height:1;'>USED</span></li>"
|
|
409
|
+
)
|
|
410
|
+
return format_html(
|
|
411
|
+
"<div style='margin-top:4px;'>"
|
|
412
|
+
"<p style='font-size:11px;color:#64748b;margin-bottom:8px;line-height:1.4;'>"
|
|
413
|
+
"Keep these 10 one-time emergency recovery codes in a safe place. Each code can be used ONCE.</p>"
|
|
414
|
+
"<ul style='display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));"
|
|
415
|
+
"gap:8px;list-style:none;padding:0;margin:0 0 12px;'>{}</ul>"
|
|
416
|
+
"<div style='display:flex;align-items:center;gap:16px;margin-top:8px;'>"
|
|
417
|
+
"<a href='/admin/auth/user/{}/download-backup-codes/' "
|
|
418
|
+
"style='background:#2563eb;color:white;font-weight:bold;font-size:11px;padding:6px 14px;"
|
|
419
|
+
"border-radius:8px;text-decoration:none;display:inline-flex;align-items:center;gap:6px;'>"
|
|
420
|
+
"📥 Download Backup Codes (.txt)</a>"
|
|
421
|
+
"<a href='/admin/auth/user/{}/generate-backup-codes/' "
|
|
422
|
+
"style='font-size:11px;color:#2563eb;font-weight:bold;text-decoration:underline;'>"
|
|
423
|
+
"🔄 Regenerate 10 New Codes</a>"
|
|
424
|
+
"</div></div>",
|
|
425
|
+
mark_safe("".join(lis)), obj.user_id, obj.user_id,
|
|
426
|
+
)
|
|
427
|
+
get_backup_codes.short_description = "10 One-Time Recovery Backup Codes"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ─────────────────────────────────────────────
|
|
431
|
+
# Public helper: get available inline classes
|
|
432
|
+
# ─────────────────────────────────────────────
|
|
433
|
+
def get_2fa_inlines():
|
|
434
|
+
"""Return tuple of available 2FA inline classes based on installed OTP packages."""
|
|
435
|
+
inlines = []
|
|
436
|
+
if HAS_OTP_TOTP:
|
|
437
|
+
inlines.append(TOTPDeviceInline)
|
|
438
|
+
if HAS_OTP_EMAIL:
|
|
439
|
+
inlines.append(EmailDeviceInline)
|
|
440
|
+
if HAS_OTP_STATIC:
|
|
441
|
+
inlines.append(StaticDeviceInline)
|
|
442
|
+
return tuple(inlines)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ─────────────────────────────────────────────
|
|
446
|
+
# AdminFlowUserAdminMixin
|
|
447
|
+
# ─────────────────────────────────────────────
|
|
448
|
+
class AdminFlowUserAdminMixin:
|
|
449
|
+
"""
|
|
450
|
+
Mixin for Django UserAdmin that adds plug-and-play 2FA support.
|
|
451
|
+
|
|
452
|
+
Usage::
|
|
453
|
+
|
|
454
|
+
from adminflow.user_admin import AdminFlowUserAdminMixin, get_2fa_inlines
|
|
455
|
+
|
|
456
|
+
@admin.register(User)
|
|
457
|
+
class UserAdmin(AdminFlowUserAdminMixin, BaseUserAdmin):
|
|
458
|
+
inlines = (YourInline,) + get_2fa_inlines()
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
def save_formset(self, request, form, formset, change):
|
|
462
|
+
instances = formset.save(commit=False)
|
|
463
|
+
for obj in formset.deleted_objects:
|
|
464
|
+
obj.delete()
|
|
465
|
+
for instance in instances:
|
|
466
|
+
if HAS_OTP_TOTP and isinstance(instance, TOTPDevice):
|
|
467
|
+
if any(getattr(f, "_is_verified", False) for f in formset.forms):
|
|
468
|
+
instance.confirmed = True
|
|
469
|
+
if not instance.confirmed:
|
|
470
|
+
if instance.pk:
|
|
471
|
+
instance.delete()
|
|
472
|
+
TOTPDevice.objects.filter(user=instance.user, confirmed=False).delete()
|
|
473
|
+
continue
|
|
474
|
+
elif HAS_OTP_EMAIL and isinstance(instance, EmailDevice):
|
|
475
|
+
if hasattr(instance, "user") and instance.user:
|
|
476
|
+
instance.email = instance.user.email or f"{instance.user.username}@example.com"
|
|
477
|
+
instance.name = "default"
|
|
478
|
+
elif HAS_OTP_STATIC and isinstance(instance, StaticDevice):
|
|
479
|
+
instance.name = "backup_codes"
|
|
480
|
+
instance.confirmed = True
|
|
481
|
+
instance.save()
|
|
482
|
+
if StaticToken.objects.filter(device=instance).count() == 0:
|
|
483
|
+
codes = []
|
|
484
|
+
for _ in range(10):
|
|
485
|
+
t = StaticToken.random_token()
|
|
486
|
+
StaticToken.objects.create(device=instance, token=t)
|
|
487
|
+
codes.append(t)
|
|
488
|
+
_save_batch_tokens(instance.pk, codes)
|
|
489
|
+
instance.save()
|
|
490
|
+
formset.save_m2m()
|
|
491
|
+
|
|
492
|
+
def get_urls(self):
|
|
493
|
+
from django.urls import path
|
|
494
|
+
urls = super().get_urls()
|
|
495
|
+
custom = []
|
|
496
|
+
if HAS_OTP_TOTP:
|
|
497
|
+
custom.append(path(
|
|
498
|
+
"<path:object_id>/verify-totp/<int:device_id>/",
|
|
499
|
+
self.admin_site.admin_view(self.verify_totp_device_view),
|
|
500
|
+
name="user_verify_totp",
|
|
501
|
+
))
|
|
502
|
+
if HAS_OTP_EMAIL:
|
|
503
|
+
custom.append(path(
|
|
504
|
+
"<path:object_id>/send-email-otp/",
|
|
505
|
+
self.admin_site.admin_view(self.send_email_otp_view),
|
|
506
|
+
name="user_send_email_otp",
|
|
507
|
+
))
|
|
508
|
+
if HAS_OTP_STATIC:
|
|
509
|
+
custom.append(path(
|
|
510
|
+
"<path:object_id>/generate-backup-codes/",
|
|
511
|
+
self.admin_site.admin_view(self.generate_backup_codes_view),
|
|
512
|
+
name="user_generate_backup_codes",
|
|
513
|
+
))
|
|
514
|
+
custom.append(path(
|
|
515
|
+
"<path:object_id>/download-backup-codes/",
|
|
516
|
+
self.admin_site.admin_view(self.download_backup_codes_view),
|
|
517
|
+
name="user_download_backup_codes",
|
|
518
|
+
))
|
|
519
|
+
return custom + urls
|
|
520
|
+
|
|
521
|
+
def download_backup_codes_view(self, request, object_id):
|
|
522
|
+
from django.http import HttpResponse
|
|
523
|
+
from django.shortcuts import get_object_or_404
|
|
524
|
+
from django.utils import timezone
|
|
525
|
+
user = get_object_or_404(User, pk=object_id)
|
|
526
|
+
device, _ = StaticDevice.objects.get_or_create(
|
|
527
|
+
user=user, name="backup_codes", defaults={"confirmed": True}
|
|
528
|
+
)
|
|
529
|
+
tokens = list(StaticToken.objects.filter(device=device).values_list("token", flat=True))
|
|
530
|
+
if not tokens:
|
|
531
|
+
tokens = []
|
|
532
|
+
for _ in range(10):
|
|
533
|
+
t = StaticToken.random_token()
|
|
534
|
+
StaticToken.objects.create(device=device, token=t)
|
|
535
|
+
tokens.append(t)
|
|
536
|
+
device.confirmed = True
|
|
537
|
+
device.save()
|
|
538
|
+
app_title = getattr(settings, "ADMINFLOW_TITLE", "AdminFlow")
|
|
539
|
+
now_str = timezone.now().strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
540
|
+
codes_txt = "\n".join([f" {i+1:2d}. {t}" for i, t in enumerate(tokens)])
|
|
541
|
+
user_label = user.email or user.get_username()
|
|
542
|
+
content = (
|
|
543
|
+
f"=====================================================\n"
|
|
544
|
+
f" {app_title.upper()} 2FA EMERGENCY RECOVERY CODES\n"
|
|
545
|
+
f"=====================================================\n"
|
|
546
|
+
f" User Email : {user_label}\n"
|
|
547
|
+
f" Generated On : {now_str}\n"
|
|
548
|
+
f"=====================================================\n\n"
|
|
549
|
+
f" IMPORTANT: Keep these codes in a safe place.\n"
|
|
550
|
+
f" Each code can be used ONCE to log in if you lose your phone.\n\n"
|
|
551
|
+
f" RECOVERY CODES:\n"
|
|
552
|
+
f" -----------------------------------------------------\n"
|
|
553
|
+
f"{codes_txt}\n"
|
|
554
|
+
f" -----------------------------------------------------\n\n"
|
|
555
|
+
f"=====================================================\n"
|
|
556
|
+
)
|
|
557
|
+
response = HttpResponse(content, content_type="text/plain; charset=utf-8")
|
|
558
|
+
response["Content-Disposition"] = f'attachment; filename="{user_label}_2fa_recovery_codes.txt"'
|
|
559
|
+
return response
|
|
560
|
+
|
|
561
|
+
def send_email_otp_view(self, request, object_id):
|
|
562
|
+
from django.contrib import messages
|
|
563
|
+
from django.shortcuts import get_object_or_404, redirect
|
|
564
|
+
user = get_object_or_404(User, pk=object_id)
|
|
565
|
+
device, _ = EmailDevice.objects.get_or_create(
|
|
566
|
+
user=user, name="default",
|
|
567
|
+
defaults={"confirmed": False, "email": user.email},
|
|
568
|
+
)
|
|
569
|
+
if not device.email:
|
|
570
|
+
device.email = user.email
|
|
571
|
+
device.save()
|
|
572
|
+
device.generate_challenge()
|
|
573
|
+
messages.success(
|
|
574
|
+
request,
|
|
575
|
+
f"Sent 6-digit verification code to {device.email}! Check your email, enter the code below, and click Save."
|
|
576
|
+
)
|
|
577
|
+
return redirect(request.META.get("HTTP_REFERER", f"/admin/auth/user/{object_id}/change/"))
|
|
578
|
+
|
|
579
|
+
def generate_backup_codes_view(self, request, object_id):
|
|
580
|
+
from django.contrib import messages
|
|
581
|
+
from django.shortcuts import get_object_or_404, redirect
|
|
582
|
+
user = get_object_or_404(User, pk=object_id)
|
|
583
|
+
device, _ = StaticDevice.objects.get_or_create(
|
|
584
|
+
user=user, name="backup_codes", defaults={"confirmed": True}
|
|
585
|
+
)
|
|
586
|
+
device.token_set.all().delete()
|
|
587
|
+
codes = []
|
|
588
|
+
for _ in range(10):
|
|
589
|
+
token = StaticToken.random_token()
|
|
590
|
+
device.token_set.create(token=token)
|
|
591
|
+
codes.append(token)
|
|
592
|
+
_save_batch_tokens(device.pk, codes)
|
|
593
|
+
messages.success(
|
|
594
|
+
request,
|
|
595
|
+
f"Successfully generated 10 emergency recovery backup codes for {user.email or user.get_username()}."
|
|
596
|
+
)
|
|
597
|
+
return redirect(request.META.get("HTTP_REFERER", f"/admin/auth/user/{object_id}/change/"))
|
|
598
|
+
|
|
599
|
+
def verify_totp_device_view(self, request, object_id, device_id):
|
|
600
|
+
from django.contrib import messages
|
|
601
|
+
from django.shortcuts import get_object_or_404, redirect
|
|
602
|
+
device = get_object_or_404(TOTPDevice, pk=device_id, user_id=object_id)
|
|
603
|
+
if request.method == "POST":
|
|
604
|
+
code = request.POST.get("verify_code", "").strip()
|
|
605
|
+
device.tolerance = 2
|
|
606
|
+
if device.verify_token(code):
|
|
607
|
+
device.confirmed = True
|
|
608
|
+
device.save()
|
|
609
|
+
messages.success(
|
|
610
|
+
request,
|
|
611
|
+
f"Verification Successful! 2FA Device '{device.name}' is now verified & active "
|
|
612
|
+
f"for {device.user.email or device.user.get_username()}."
|
|
613
|
+
)
|
|
614
|
+
else:
|
|
615
|
+
messages.error(
|
|
616
|
+
request,
|
|
617
|
+
f"Verification Failed: '{code}' is not a valid code for '{device.name}'. "
|
|
618
|
+
"Check your device time sync and try again."
|
|
619
|
+
)
|
|
620
|
+
return redirect(request.META.get("HTTP_REFERER", f"/admin/auth/user/{object_id}/change/"))
|
|
621
|
+
|
|
622
|
+
@admin.action(description="Enable 2FA (Create TOTP Device) for selected users")
|
|
623
|
+
def enable_2fa_otp(self, request, queryset):
|
|
624
|
+
if not HAS_OTP_TOTP:
|
|
625
|
+
return
|
|
626
|
+
count = 0
|
|
627
|
+
for u in queryset:
|
|
628
|
+
device, _ = TOTPDevice.objects.get_or_create(user=u, name="default", defaults={"confirmed": True})
|
|
629
|
+
if not device.confirmed:
|
|
630
|
+
device.confirmed = True
|
|
631
|
+
device.save()
|
|
632
|
+
count += 1
|
|
633
|
+
self.message_user(request, f"Successfully enabled 2FA OTP for {count} user(s).")
|
|
634
|
+
|
|
635
|
+
@admin.action(description="Generate 10 Emergency Backup Codes for selected users")
|
|
636
|
+
def generate_10_backup_codes(self, request, queryset):
|
|
637
|
+
if not HAS_OTP_STATIC:
|
|
638
|
+
return
|
|
639
|
+
count = 0
|
|
640
|
+
for u in queryset:
|
|
641
|
+
device, _ = StaticDevice.objects.get_or_create(user=u, name="backup_codes", defaults={"confirmed": True})
|
|
642
|
+
device.token_set.all().delete()
|
|
643
|
+
for _ in range(10):
|
|
644
|
+
device.token_set.create(token=StaticToken.random_token())
|
|
645
|
+
count += 1
|
|
646
|
+
self.message_user(request, f"Successfully generated 10 emergency backup codes for {count} user(s).")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def unregister_otp_models():
|
|
650
|
+
"""Remove standalone OTP device models from the admin sidebar."""
|
|
651
|
+
_models = []
|
|
652
|
+
if HAS_OTP_TOTP:
|
|
653
|
+
_models.append(TOTPDevice)
|
|
654
|
+
if HAS_OTP_EMAIL:
|
|
655
|
+
_models.append(EmailDevice)
|
|
656
|
+
if HAS_OTP_STATIC:
|
|
657
|
+
_models.append(StaticDevice)
|
|
658
|
+
for m in _models:
|
|
659
|
+
try:
|
|
660
|
+
admin.site.unregister(m)
|
|
661
|
+
except Exception:
|
|
662
|
+
pass
|