A drop-in Django admin site that replaces the default login with a two-step email OTP flow, with optional Unfold UI support.
Project description
django-otp-admin
A drop-in Django admin site that replaces the default username/password login with a two-step email OTP flow.
All OTP state is stored in Redis via Django's cache framework — no extra database table required.
Works with plain Django admin and optionally with Unfold.
Login flow
/admin/
│
▼
GET /admin/login/ → Enter email address
POST /admin/login/ → OTP sent to email
│
▼
GET /admin/verify-otp/ → Enter 6-digit code
POST /admin/verify-otp/ → Code verified
│
▼
login(request, user) → Normal Django admin session ✅
Features
- 🔐 Two-step login — email address then OTP code
- ⚡ Redis-backed — OTPs stored with automatic TTL expiry, no DB migrations
- 🔄 Auto-mirroring — all models registered on
admin.siteappear automatically - 🎨 Unfold-compatible — inherits Unfold's admin site when installed, falls back gracefully
- 🛡️ Security-first:
- Replay-attack prevention (OTP keys deleted on first use)
- Session-fixation prevention (Django's
login()rotates session key) - User enumeration protection (generic error for unknown emails)
- Rate-limiting (one OTP request per email per 60 seconds)
- 📧 Graceful mail errors — mail server failures show a friendly message instead of a 500
Requirements
| Dependency | Version |
|---|---|
| Python | ≥ 3.6 |
| Django | ≥ 2.2 |
| django-redis | ≥ 4.9 |
| django-unfold | optional |
Installation
pip install django-otp-admin
# With Unfold support
pip install django-otp-admin[unfold]
Quick start
1. settings.py
INSTALLED_APPS = [
"unfold", # optional — must precede django.contrib.admin
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_otp_admin", # after django.contrib.admin
# ... your apps
]
# Redis cache (required for OTP storage)
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
# Email backend
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
DEFAULT_FROM_EMAIL = "no-reply@yourdomain.com"
2. urls.py
from django_otp_admin.site import otp_admin_site # replaces: from django.contrib import admin
urlpatterns = [
path("admin/", otp_admin_site.urls), # replaces: admin.site.urls
# ...
]
3. yourapp/admin.py
No changes needed. Standard @admin.register() works as-is:
from django.contrib import admin
from .models import MyModel
@admin.register(MyModel) # no site= argument required
class MyModelAdmin(admin.ModelAdmin):
list_display = ("id", "name")
The package mirrors everything from admin.site automatically, including Django's built-in User, Group, and any third-party models.
If you need a model to appear only on this site (not on admin.site), use the explicit form:
from django_otp_admin.site import otp_admin_site
@admin.register(MyModel, site=otp_admin_site)
class MyModelAdmin(admin.ModelAdmin):
...
Configuration
All settings are optional. Override in settings.py:
| Setting | Type | Default | Description |
|---|---|---|---|
OTP_ADMIN_TTL |
int |
300 |
OTP lifetime in seconds |
OTP_ADMIN_COOLDOWN |
int |
60 |
Min seconds between OTP requests per email |
OTP_ADMIN_SITE_NAME |
str |
"Admin" |
Prefix used in the OTP email subject line |
Example:
OTP_ADMIN_TTL = 600 # 10 minutes
OTP_ADMIN_COOLDOWN = 120 # 2 minutes between requests
OTP_ADMIN_SITE_NAME = "Acme Corp"
INSTALLED_APPS order
The order matters:
INSTALLED_APPS = [
"unfold", # 1. Unfold must precede django.contrib.admin
"django.contrib.admin", # 2. Django admin
...
"django_otp_admin", # 3. This package — after django.contrib.admin
"yourapp", # 4. Your apps — last
]
Running the tests
pip install -e ".[dev]"
pytest --ds=tests.settings
Security notes
| Concern | Mitigation |
|---|---|
| Replay attacks | OTP key is deleted from Redis immediately after the first successful use |
| Session fixation | Django's login() rotates the session key on authentication |
| User enumeration | Unknown emails receive the same generic response as known emails |
| OTP brute-force | Codes expire after OTP_ADMIN_TTL seconds; rate-limited per email |
| Mail server downtime | Caught and surfaced as a user-friendly message; no 500 errors |
Project structure
django_otp_admin/
├── __init__.py # version, public API surface
├── apps.py # AppConfig
├── site.py # OTPAdminSite — the main class + singleton
├── forms.py # AdminEmailForm, AdminOTPForm
├── utils.py # generate_otp, is_valid_otp, send_admin_otp
└── templates/
└── admin/
├── email_login.html
└── otp_verify.html
Contributing
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-change - Make your changes with tests
- Run the test suite:
pytest --ds=tests.settings - Open a pull request
Changelog
See CHANGELOG.md.
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_otp_admin-1.0.2.tar.gz.
File metadata
- Download URL: django_otp_admin-1.0.2.tar.gz
- Upload date:
- Size: 20.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3ba48d8a3fcd466ce6d698e8756e02fdc940e5dd59e337937cc9e2f3c6ea380
|
|
| MD5 |
00996ccd7f08930e728c5e01cf4cf98f
|
|
| BLAKE2b-256 |
71e7ca084034b8824f266c50ed357c1f66ff61339ec1db139817ebef63346096
|
File details
Details for the file django_otp_admin-1.0.2-py3-none-any.whl.
File metadata
- Download URL: django_otp_admin-1.0.2-py3-none-any.whl
- Upload date:
- Size: 20.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e076fa05965f9ea6a28134013a630fd68427f9f18cb5c447a4be5f1cbafe64b8
|
|
| MD5 |
293e503c7fb05936ec33c2b2830915d8
|
|
| BLAKE2b-256 |
34451815fd9f4e7e73eb14127951dd3454f1333eefd00873eb2e206b3f14bd63
|