رمز یکبارمصرف؛ ساده برای کاربر، حساس برای شما
کاربر ایرانی به ورود با پیامک عادت دارد: شماره را وارد میکند، کد پنجرقمی میگیرد و وارد میشود. پیادهسازی سادهی آن در ده دقیقه تمام میشود؛ پیادهسازی امنش چند نکته دارد که اگر رعایت نشود، مهاجم میتواند کد را حدس بزند، هزینهی پیامک شما را بالا ببرد یا وجود کاربران را کشف کند.
قواعد طراحی
- کد با
secretsساخته شود نهrandom، و فقط هش آن ذخیره شود. - انقضای کوتاه (۲ دقیقه) و حداکثر ۵ تلاش برای هر کد؛ بعد از آن کد باطل شود.
- فاصلهی زمانی بین دو ارسال برای یک شماره (۶۰ ثانیه) و سقف ارسال برای هر IP در ساعت.
- پاسخ برای شمارهی ثبتشده و ثبتنشده یکسان باشد.
- ارسال پیامک در پسزمینه (Celery) و بعد از commit.
# apps/accounts/otp.py
import hashlib
import hmac
import secrets
import time
from django.conf import settings
from django.core.cache import cache
from .tasks import send_otp_sms
OTP_TTL, COOLDOWN, MAX_TRIES, IP_LIMIT = 120, 60, 5, 10
class OTPError(Exception):
pass
def _digest(mobile, code):
msg = f"{mobile}:{code}".encode()
return hmac.new(settings.SECRET_KEY.encode(), msg, hashlib.sha256).hexdigest()
def request_otp(mobile, ip):
if not cache.add(f"otp:cd:{mobile}", 1, timeout=COOLDOWN):
raise OTPError("کد قبلاً ارسال شده؛ یک دقیقه صبر کنید.")
ip_key = f"otp:ip:{ip}"
cache.add(ip_key, 0, timeout=3600)
if cache.incr(ip_key) > IP_LIMIT:
raise OTPError("تعداد درخواستها بیش از حد مجاز است.")
code = f"{secrets.randbelow(100000):05d}"
cache.set(f"otp:{mobile}", {"h": _digest(mobile, code), "tries": 0,
"exp": time.time() + OTP_TTL}, timeout=OTP_TTL)
send_otp_sms.delay(mobile, code)
def verify_otp(mobile, code):
key = f"otp:{mobile}"
data = cache.get(key)
if not data or data["tries"] >= MAX_TRIES:
cache.delete(key)
return False
if hmac.compare_digest(data["h"], _digest(mobile, code)):
cache.delete(key) # یکبارمصرف
return True
data["tries"] += 1
remaining = int(data["exp"] - time.time())
if remaining > 0:
cache.set(key, data, timeout=remaining) # TTL تمدید نشود
return False
# apps/accounts/views.py (بخش تأیید)
if verify_otp(mobile, code):
user, created = User.objects.get_or_create(mobile=mobile)
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect(request.GET.get("next") or "orders:list") # next را با url_has_allowed_host_and_scheme بررسی کنید
نکتههایی که کمتر کسی میداند
- کش پیشفرض (LocMemCache) برای هر پروسهی gunicorn جداست؛ با چهار worker کد ممکن است در یکی ذخیره و در دیگری جستوجو شود. OTP و rate limit حتماً Redis میخواهند.
- شمارش تلاشها با get/set در شرایط همزمانی قابل دور زدن است (چند درخواست موازی)؛ در مقیاس جدی از
incrاتمیک Redis برای شمارندهی تلاش استفاده کنید. - بیشتر سرویسهای پیامک ایرانی متد «ارسال با الگو» (verify/lookup) دارند که از خط خدماتی و بدون فیلتر بلکلیست مخابرات میرود و بسیار سریعتر از ارسال متن آزاد میرسد.
autocomplete="one-time-code"وinputmode="numeric"روی input کد باعث میشود گوشی کد پیامک را خودش پیشنهاد دهد و کیبورد عددی باز شود.- هنگام لاگین دستی کاربری که با authenticate احراز نشده، پارامتر
backendدرlogin()الزامی است وقتی بیش از یک backend درAUTHENTICATION_BACKENDSدارید. - کد OTP را هرگز در لاگ ننویسید، حتی در DEBUG؛ لاگها معمولاً دسترسی گستردهتری از پایگاه داده دارند.