python 70 lines · 9 steps

Two-factor login with OTP in Django

A password check triggers a one-time SMS code, then a second view verifies it before the session is authenticated.

Explained by highlit
1import secrets
2 
3from django.contrib.auth import authenticate, login
4from django.core.cache import cache
5from django.shortcuts import redirect, render
6from django.views import View
7 
8from .tasks import send_otp_sms
9 
10OTP_TTL = 300
11OTP_MAX_ATTEMPTS = 5
12 
13 
14class LoginStartView(View):
15 template_name = "accounts/login.html"
16 
17 def get(self, request):
18 return render(request, self.template_name)
19 
20 def post(self, request):
21 user = authenticate(
22 request,
23 username=request.POST.get("username"),
24 password=request.POST.get("password"),
25 )
26 if user is None:
27 return render(request, self.template_name, {"error": "Invalid credentials"})
28 
29 code = f"{secrets.randbelow(1_000_000):06d}"
30 cache.set(f"otp:{user.pk}", {"code": code, "attempts": 0}, OTP_TTL)
31 send_otp_sms.delay(user.pk, code)
32 
33 request.session["pending_user"] = user.pk
34 return redirect("accounts:login-verify")
35 
36 
37class LoginVerifyView(View):
38 template_name = "accounts/verify.html"
39 
40 def dispatch(self, request, *args, **kwargs):
41 if "pending_user" not in request.session:
42 return redirect("accounts:login")
43 return super().dispatch(request, *args, **kwargs)
44 
45 def get(self, request):
46 return render(request, self.template_name)
47 
48 def post(self, request):
49 user_id = request.session["pending_user"]
50 key = f"otp:{user_id}"
51 entry = cache.get(key)
52 if entry is None:
53 del request.session["pending_user"]
54 return render(request, self.template_name, {"error": "Code expired, start over"})
55 
56 entry["attempts"] += 1
57 if entry["attempts"] >= OTP_MAX_ATTEMPTS:
58 cache.delete(key)
59 del request.session["pending_user"]
60 return render(request, self.template_name, {"error": "Too many attempts"})
61 
62 if not secrets.compare_digest(entry["code"], request.POST.get("code", "")):
63 cache.set(key, entry, OTP_TTL)
64 return render(request, self.template_name, {"error": "Incorrect code"})
65 
66 cache.delete(key)
67 del request.session["pending_user"]
68 user = authenticate(request, user_id=user_id)
69 login(request, user)
70 return redirect("dashboard")
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Splitting a multi-step flow across views keeps each request focused and lets session state carry the user between them.
  2. 2A short-TTL cache entry with an attempt counter gives you both automatic expiry and brute-force protection for free.
  3. 3Using secrets for code generation and comparison avoids predictable codes and timing side channels.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Two-factor login with OTP in Django — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code