Skip to main content

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.site appear 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

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/my-change
  3. Make your changes with tests
  4. Run the test suite: pytest --ds=tests.settings
  5. Open a pull request

Changelog

See CHANGELOG.md.


License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_otp_admin-1.0.1.tar.gz (20.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_otp_admin-1.0.1-py3-none-any.whl (20.3 kB view details)

Uploaded Python 3

File details

Details for the file django_otp_admin-1.0.1.tar.gz.

File metadata

  • Download URL: django_otp_admin-1.0.1.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

Hashes for django_otp_admin-1.0.1.tar.gz
Algorithm Hash digest
SHA256 be891cf2272ce09aed68ac5397c5642d877391e9bb38adbffb4b0786d7934aec
MD5 a7467bcb632d4496bb639977cd4115e4
BLAKE2b-256 395b10987f1e505ce0bb6cc48b09d178fbe9a681d735dc0bbbe84f67524da096

See more details on using hashes here.

File details

Details for the file django_otp_admin-1.0.1-py3-none-any.whl.

File metadata

File hashes

Hashes for django_otp_admin-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 5457b55dd5844ae1f78426e130e1895173b2f1660d501664d03f34a0cf553cbc
MD5 23c2f3423a10e67c67e916f56c332ea3
BLAKE2b-256 e19406950d9c4d02df194c043b5ec1193fa15d5b5b372a0e4e33c0e70cf88873

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page