บทความทั้งหมด
DjangoPythonwebhook debugginglocal testing

Django webhook: ผ่าน CSRF ไปถึง handler

ตามค่าเริ่มต้น Django จะปฏิเสธทุก POST ของ webhook ด้วย 403 ตัวการคือ CSRF middleware ที่กำลังทำงานของมัน — ป้องกัน cross-site request forgery — เพียงแต่ผู้ให้บริการไม่ใช่เบราว์เซอร์ และไม่มี session cookie หรือ CSRF token วิธีแก้คือ decorator หนึ่งตัว และความเข้าใจหนึ่งบรรทัดเรื่องการเข้าถึง body ของคำขอ

ข้าม CSRF สำหรับวิว webhook

วิธีแบบ Django:

from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse, HttpResponseBadRequest
import hmac, hashlib

@csrf_exempt
def stripe_webhook(request):
    if request.method != 'POST':
        return HttpResponseBadRequest('POST only')

    payload = request.body            # ไบต์ดิบ ไม่ถูกแตะ
    signature = request.headers.get('Stripe-Signature', '')

    # ตรวจสอบลายเซ็น (โค้ดจริงใช้ stripe SDK)
    if not verify(payload, signature, settings.STRIPE_WEBHOOK_SECRET):
        return HttpResponse(status=401)

    event = json.loads(payload)
    handle_stripe_event(event)
    return HttpResponse(status=200)

decorator @csrf_exempt ลบการตรวจของ middleware เฉพาะวิวนี้ — ฟอร์มอื่นยังได้รับการป้องกัน request.body คืนไบต์ดิบตามที่มาถึง ซึ่งคือสิ่งที่การตรวจสอบลายเซ็นต้องการพอดี

ถ้าคุณใช้ Django REST Framework

DRF ห่อคำขอด้วยออบเจ็กต์ Request ของตัวเองและรัน parser ตาม content-type พอโค้ดวิวอ่าน request.data parser JSON ก็กิน body ไปแล้ว มีทางออกสองทาง:

ตัวเลือก A: ปล่อยเป็นวิว Django ธรรมดา

handler ของ webhook ไม่ต้องใช้ parser ของ DRF, browsable API หรือกลไก auth วิว @csrf_exempt ธรรมดาข้าง ๆ endpoint DRF ของคุณคือแพตเทิร์นที่สะอาดที่สุด

ตัวเลือก B: ใช้ DRF ด้วยการเข้าถึงแบบดิบ

ถ้าอยากใช้ DRF จริง ๆ ให้อ่าน request.body (คำขอ Django ที่อยู่เบื้องล่าง) แทน request.data:

from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny

@api_view(['POST'])
@authentication_classes([])         # ไม่มี auth สำหรับ webhook
@permission_classes([AllowAny])     # ผู้ให้บริการ auth ด้วยลายเซ็นแล้ว
def stripe_webhook(request):
    payload = request._request.body  # คำขอ Django เบื้องล่าง ไบต์ดิบ
    signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')
    # ...ตรวจสอบและจัดการ

การอ้อม request._request ดูเก้งก้าง แต่เป็นทางหนีอย่างเป็นทางการเมื่อคุณต้องเลี่ยงการ parse ของ DRF

ตั้งค่า tunnel

  1. รันเซิร์ฟเวอร์ dev ของ Django: python manage.py runserver 8000
  2. เพิ่ม hostname ของ tunnel ลงใน ALLOWED_HOSTS ใน settings.py ง่ายที่สุดคือ ['*'] ระหว่าง dev ในเครื่อง หรือโดเมน tunnel เฉพาะถ้าชอบ
  3. npx portpreview 8000 ในเทอร์มินัลแยก
  4. วาง URL tunnel พร้อม path ของ webhook ลงในแดชบอร์ดผู้ให้บริการ
  5. ทริกเกอร์อีเวนต์ทดสอบแล้วดูมันมาถึงวิวของคุณ

ลืม ALLOWED_HOSTS จะได้ Django 400 ทั่วไปโดยไม่มีสาเหตุชัดเจน ถ้าการส่ง webhook ครั้งแรกคืน 400 และคุณไม่เห็นล็อกของวิว นั่นคือเหตุผล

กับดักที่พบบ่อย

อ่าน body สองครั้ง

เหมือนทุกเฟรมเวิร์ก Django ให้ body เป็นสตรีมที่อ่านได้ครั้งเดียว ถ้า middleware หรือ logging wrapper แตะ request.body ไปแล้ว โค้ดวิวจะเห็นออบเจ็กต์ bytes ว่าง พิมพ์ len(request.body) เพื่อตรวจสอบ

โซนเวลาและความคลาดเคลื่อนของ timestamp

ถ้าเทียบ timestamp ที่เซ็นกับ datetime.now() ให้ใช้ timezone.now() หรือเทียบใน UTC การเทียบ datetime แบบ naive จะล้มเป็นระยะเมื่อมีการเปลี่ยน daylight saving

วิว async

Django 4+ รองรับวิว async def handler ของ webhook เป็น async ได้ แต่พึงระวังว่า SDK ตรวจลายเซ็น (Stripe Python ฯลฯ) เป็น sync รันด้วย asgiref.sync_to_async หรือเก็บทั้งวิวเป็น sync

หมายเหตุการดีพลอยโปรดักชัน

เมื่อผ่านการทดสอบในเครื่องแล้ว วิวเดียวกันทำงานบนโปรดักชัน Gunicorn, uWSGI, Daphne — ไม่มีตัวไหนแตะ request.body ก่อนที่วิวจะเห็น reverse proxy ของคุณอาจแตะ (เช่น nginx ที่เปิด proxy_buffering) แต่นั่นเรื่อง buffering ไม่ใช่การแก้ body

สำหรับทฤษฎีลายเซ็นเบื้องล่าง ดู คู่มือตรวจสอบลายเซ็น สำหรับแพตเทิร์นเฉพาะ Stripe ฝั่งแดชบอร์ดดูได้ที่ คู่มือทดสอบ Stripe ในเครื่อง เข้าร่วมรายชื่อรอของ PortPreview เพื่อ tunnel ที่เข้ากันดีกับเซิร์ฟเวอร์ dev ของ Python

คำถามที่พบบ่อย

ทำไม Django คืน 403 กับทุก POST ของ webhook?
CSRF middleware ของ Django ปฏิเสธ POST ที่ไม่มี CSRF token ที่ถูกต้อง ผู้ให้บริการ webhook ไม่ได้ส่งมาให้ เพิ่ม @csrf_exempt ที่วิว webhook (เฉพาะวิวนั้น) เพื่อข้ามการตรวจ แล้วตรวจสอบลายเซ็นแทน
วิว webhook ควรเป็น Django ธรรมดาหรือ DRF?
Django ธรรมดาง่ายกว่าสำหรับ webhook parser ของ DRF กิน body ก่อนที่วิวจะเห็น ซึ่งทำให้การตรวจลายเซ็นเสีย ถ้าคุณยังใช้ DRF ให้อ่าน request._request.body เพื่อได้ไบต์ดิบ
ค่า ALLOWED_HOSTS สำหรับ tunnel ใน Django คืออะไร?
เพิ่มโดเมน tunnel ลงใน ALLOWED_HOSTS ใน settings.py ระหว่าง dev ในเครื่อง ['*'] ใช้ได้ สำหรับการตั้งค่าที่เข้มงวดให้ระบุ hostname ของ tunnel เฉพาะ ถ้าไม่มีสิ่งนี้ Django จะคืน 400 โดยไม่มีข้อความล็อกที่ชัดเจน