فصل ۶: ادمین، احراز هویت و دسترسی

ورود با OTP پیامکی: طراحی امن، انقضا و محدودیت نرخ

رمز یک‌بارمصرف؛ ساده برای کاربر، حساس برای شما

کاربر ایرانی به ورود با پیامک عادت دارد: شماره را وارد می‌کند، کد پنج‌رقمی می‌گیرد و وارد می‌شود. پیاده‌سازی ساده‌ی آن در ده دقیقه تمام می‌شود؛ پیاده‌سازی امنش چند نکته دارد که اگر رعایت نشود، مهاجم می‌تواند کد را حدس بزند، هزینه‌ی پیامک شما را بالا ببرد یا وجود کاربران را کشف کند.

قواعد طراحی

  • کد با 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؛ لاگ‌ها معمولاً دسترسی گسترده‌تری از پایگاه داده دارند.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.