探索Django驗證碼功能的實現 - DjangoStarter專案模板裡的封裝

程式設計實驗室發表於2022-04-09

前言

依然是最近在做的這個專案,用Django做後端,App上提交資訊的時候需要一個驗證碼來防止使用者亂提交,正好我的「DjangoStarter」專案腳手架也有封裝了驗證碼功能,不過我發現好像裡面只是把驗證碼作為admin後臺登入的校驗手段,並沒有給出前後端分離專案的驗證碼相關介面。

所以本文介紹驗證碼功能實現的同時,也對「DjangoStarter」的驗證碼模組做一層封裝,使其更方便使用~

用哪個庫好呢

Python之禪:人生苦短,不造輪子

——魯迅:我說的

我在「DjangoStarter」裡選擇的是django-simple-captchadjango-multi-captcha-admin這倆庫,前者提供生成、儲存驗證碼的功能;後者可以將驗證碼整合到Django Admin的登入頁面裡。

開始

所以我們現在具備了實現驗證碼功能的基礎,那麼該如何在前端獲取驗證碼呢?

首先django-simple-captcha這個庫既然是要提供驗證碼功能,那肯定有相關介面吧,來看看官網文件?no,這文件也太簡陋了

算了,直接看原始碼吧

在新增這個庫到專案裡的時候,需要配置這個路由:

path('captcha/', include('captcha.urls'))

那我們就從路由(captcha.urls)開始看

它的路由配置程式碼是這樣的

urlpatterns = [
    url(
        r"image/(?P<key>\w+)/$",
        views.captcha_image,
        name="captcha-image",
        kwargs={"scale": 1},
    ),
    url(
        r"image/(?P<key>\w+)@2/$",
        views.captcha_image,
        name="captcha-image-2x",
        kwargs={"scale": 2},
    ),
    url(r"audio/(?P<key>\w+).wav$", views.captcha_audio, name="captcha-audio"),
    url(r"refresh/$", views.captcha_refresh, name="captcha-refresh"),
]

可以看到有三種連結形式,分別是

  • image/xxx
  • audio/xxx
  • refresh

嘗試

那很顯然,重新整理驗證碼的就是最後這個refresh

然後我試著在Postman裡訪問captcha/refresh/,發現直接報404

What?這個連結明明存在的,咋回事

只能繼續看看原始碼了

直接看這個 views.captcha_refresh() 方法的原始碼!

def captcha_refresh(request):
    """  Return json with new captcha for ajax refresh request """
    if not request.headers.get('x-requested-with') == 'XMLHttpRequest':
        raise Http404

    new_key = CaptchaStore.pick()
    to_json_response = {
        "key": new_key,
        "image_url": captcha_image_url(new_key),
        "audio_url": captcha_audio_url(new_key) if settings.CAPTCHA_FLITE_PATH else None,
    }
    return HttpResponse(json.dumps(to_json_response), content_type="application/json")

然後在原始碼裡面看到了這個:

if not request.headers.get('x-requested-with') == 'XMLHttpRequest': 
    raise Http404

坑爹啊!

什麼年代了,還搞jQuery的Ajax那一套是吧?

果斷棄坑!

哦不,棄坑是不可能的,有現成的東西為啥不用,我直接自己重新封裝一個不就好了?

重新封裝一個模組

contrib目錄下建立一個新的Python Package,名字就叫captcha好了

然後編輯contrib/captcha/__init__.py檔案

from captcha.conf import settings
from captcha.models import CaptchaStore
from captcha.helpers import captcha_audio_url, captcha_image_url


class CaptchaItem(object):
    def __init__(self, key, image_url, audio_url):
        self.key = key
        self.image_url = image_url
        self.audio_url = audio_url


def refresh() -> CaptchaItem:
    """
    獲取新的驗證碼

    :return:
    """
    key = CaptchaStore.pick()
    return CaptchaItem(
        key,
        captcha_image_url(key),
        captcha_audio_url(key) if settings.CAPTCHA_FLITE_PATH else None,
    )


def verify(key: str, code: str) -> bool:
    """
    檢查輸入的驗證碼是否正確

    :param key:
    :param code:
    :return:
    """
    # 清理過期的驗證碼記錄
    CaptchaStore.remove_expired()
    try:
        CaptchaStore.objects.get(response=code, hashkey=key).delete()
        return True
    except CaptchaStore.DoesNotExist:
        return False

程式碼裡面註釋很清楚了,我可以不用解釋了,哈哈

寫個新的驗證碼介面

眾所周知,「DjangoStarter」有一個預設的應用apps.core,那我們就把驗證碼的介面寫在這個app裡面就好了

apps/core/views.py裡增加程式碼

from drf_yasg2.utils import swagger_auto_schema
from rest_framework import permissions
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response

@swagger_auto_schema(method='get', operation_summary='重新整理驗證碼')
@permission_classes([permissions.AllowAny])
@api_view()
def refresh_captcha(request):
    from contrib import captcha
    captcha_item = captcha.refresh()
    return Response({
        "key": captcha_item.key,
        "image_url": captcha_item.image_url,
        "audio_url": captcha_item.audio_url,
    })

然後編輯apps/core/urls.py,新增一下路由配置

from . import views

urlpatterns = [
   	...
    path('refresh_captcha', views.refresh_captcha),
]

OK搞定啦~!

測試一下看看,在Swagger或者Postman裡請求一下這個介面:core/refresh_captcha,得到結果

{
  "message": "請求成功",
  "code": 200,
  "data": {
    "key": "f5275573b0715d2fa9613a73f80a4161ed759061",
    "image_url": "/captcha/image/f5275573b0715d2fa9613a73f80a4161ed759061/",
    "audio_url": null
  }
}

結果裡除了我們期待的驗證碼圖片地址,還有一個key,客戶端在提交使用者輸入的驗證碼時,要把key一併提交,服務端才能驗證這個提交是否有效。

檢查驗證碼是否匹配

獲取驗證碼有了,接下來要做的是檢查使用者輸入的驗證碼是否正確

在前面的封裝裡,我們已經寫了verify函式,只需要傳入驗證碼的key和使用者輸入的code就好~

正確的話會返回True,並且把這條驗證碼的記錄刪除,不存在或者錯誤的話返回False

來一個例子吧,這個介面使用的是POST方法,引數在FormData裡

from rest_framework import status
from rest_framework.response import Response
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg2 import openapi
from contrib import captcha

@swagger_auto_schema(
    method='post',
    operation_summary='檢查驗證碼',
    manual_parameters=[
        openapi.Parameter('code', openapi.IN_FORM, type=openapi.TYPE_STRING, description='驗證碼'),
        openapi.Parameter('key', openapi.IN_FORM, type=openapi.TYPE_STRING, description='驗證key'),
    ]
)
@api_view()
def verify_captcha(request):
    code = request.POST.get('code')
    key = request.POST.get('key')
    if not (code and key):
        return Response({'message': '請輸入驗證碼'}, status=status.HTTP_400_BAD_REQUEST)

    if captcha.verify(key, code):
        return Response({'message': '驗證碼輸入正確'})
    else:
        return Response({'message': '驗證碼錯誤'}, status=status.HTTP_403_FORBIDDEN)

高階用法

前面介紹的只是最基礎的用法,可以根據實際需求來自定義生成驗證碼的行為,比如手動指定驗證碼有效期之類的

要自定義的話,首先得了解驗證碼生成的過程

先來看看資料庫表是什麼樣的:

challenge response hashkey expiration id
LOKJ lokj 286f34637808d669f4fd55ebb1877f72d4ab7fa9 2022-04-08 15:32:41.328754 31
JDNA jdna fb1e57277df26cbd7c20f6a7887f0bed18972e5b 2022-04-08 15:32:45.795259 32

可以看到有五個欄位,其中expiration欄位就是指定過期時間了

之前封裝生成驗證碼方法的時候,可以看到生成的時候是呼叫CaptchaStore.pick()這個方法

其實這個CaptchaStoredjango-simple-captcha這個庫定義的一個Django Model,作者在這個model裡定義了pick這個類方法(class method)來生成驗證碼,我們來看看原始碼實現

@classmethod
def pick(cls):
    if not captcha_settings.CAPTCHA_GET_FROM_POOL:
        return cls.generate_key()

    def fallback():
        logger.error("Couldn't get a captcha from pool, generating")
        return cls.generate_key()

    # Pick up a random item from pool
    minimum_expiration = timezone.now() + datetime.timedelta(
        minutes=int(captcha_settings.CAPTCHA_GET_FROM_POOL_TIMEOUT)
    )
    store = (
        cls.objects.filter(expiration__gt=minimum_expiration).order_by("?").first()
    )

    return (store and store.hashkey) or fallback()

注意minimum_expiration = timezone.now() + datetime.timedelta這行程式碼,它的作用是從配置中讀取過期時間

所以我們其實也不用折騰,直接在設定裡配置一下就好了

不過注意這裡面captcha_settings的引入方式是:from captcha.conf import settings as captcha_settings

它是對Django的settings包裝了一層

具體原始碼就不展開了

反正我們在Django的settings裡面配置CAPTCHA_GET_FROM_POOL_TIMEOUT=10就好了,注意時間單位是分鐘

參考資料

相關文章