Skip to content

feat: Captcha #2913

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/common/constants/cache_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class Cache_Version(Enum):
# 当前用户所有权限
PERMISSION_LIST = "PERMISSION:LIST", lambda user_id: user_id

CAPTCHA = "CAPTCHA", lambda captcha: captcha

def get_version(self):
return self.value[0]

Expand Down
9 changes: 9 additions & 0 deletions apps/common/utils/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
@desc:
"""
import hashlib

import random
import io
import mimetypes
import re
Expand Down Expand Up @@ -48,6 +50,13 @@ def group_by(list_source: List, key):
return result



CHAR_SET = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']


def get_random_chars(number=6):
return "".join([CHAR_SET[random.randint(0, len(CHAR_SET) - 1)] for index in range(number)])

def encryption(message: str):
"""
加密敏感字段数据 加密方式是 如果密码是 1234567890 那么给前端则是 123******890
Expand Down
9 changes: 9 additions & 0 deletions apps/locales/en_US/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,12 @@ msgstr ""
#: .\apps\users\views\user.py:24 .\apps\users\views\user.py:25
msgid "Get current user information"
msgstr ""

msgid "Get captcha"
msgstr ""

msgid "captcha"
msgstr ""

msgid "Captcha code error or expiration"
msgstr ""
7 changes: 7 additions & 0 deletions apps/locales/zh_CN/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,11 @@ msgstr "用户管理"
msgid "Get current user information"
msgstr "获取当前用户信息"

msgid "Get captcha"
msgstr "获取验证码"

msgid "captcha"
msgstr "验证码"

msgid "Captcha code error or expiration"
msgstr "验证码错误或过期"
9 changes: 9 additions & 0 deletions apps/locales/zh_Hant/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,12 @@ msgstr "用戶管理"
#: .\apps\users\views\user.py:24 .\apps\users\views\user.py:25
msgid "Get current user information"
msgstr "獲取當前用戶資訊"

msgid "Get captcha"
msgstr "獲取驗證碼"

msgid "captcha"
msgstr "驗證碼"

msgid "Captcha code error or expiration"
msgstr "驗證碼錯誤或過期"
13 changes: 12 additions & 1 deletion apps/users/api/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from common.mixins.api_mixin import APIMixin
from common.result import ResultSerializer
from users.serializers.login import LoginResponse, LoginRequest
from users.serializers.login import LoginResponse, LoginRequest, CaptchaResponse


class ApiLoginResponse(ResultSerializer):
Expand Down Expand Up @@ -40,3 +40,14 @@ def get_request():
@staticmethod
def get_response():
return ApiLoginResponse


class ApiCaptchaResponse(ResultSerializer):
def get_data(self):
return CaptchaResponse()


class CaptchaAPI(APIMixin):
@staticmethod
def get_response():
return ApiCaptchaResponse
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code provided has a few minor improvements and optimizations:

Improvements and Optimizations:

  1. Namespace Correction:

    from .login import CaptchaResponse

    Adding . before login should correctly reference the module if it's in the same package.

  2. Import Style:
    You can use from users.serializers.login import LoginResponse, LoginRequest directly without aliasing them again within the same module.

  3. Method Overload:
    The methods ApiLoginResponse.get_request() and ApiCaptchaAPI.get_response() are staticmethod calls that could be combined into one method for cleaner code:

    class API(BaseAPIMixin):
        @classmethod
        async def get(cls):
            # Implement the logic to handle different responses
  4. Consistency:
    Naming conventions might be slightly inconsistent (e.g., CaptchaResponse vs. CaptchaAPI). Ensure consistency across your project.

Minor Issues:

  1. Result Serializer Imports:
    Make sure all necessary imports for ResultSerializer are included at the beginning of the file.

  2. Static Method Usage:
    If these classes are meant to interact with APIs, they should inherit from more concrete classes like FlaskView or FastAPIRouter rather than directly using APIMixin.

  3. Error Handling:
    Consider adding error handling within the login/response methods according to the HTTP status codes and response formats expected from an API.

Here is a revised version incorporating some of these suggestions:

import logging

from common.mixins.apimixin import Apimixin
from common.result import result_serializer_class_registry as RsClsReg
from users.model.user_models import LoginPayload, RegisterPayload, UserTokenInfo
from users.service import UserService


_LOGGER = logging.getLogger(__name__)


# Add namespace here if your CaptchaResponse is elsewhere
class CaptchaResponse:
    pass

class ApiLoginResponse(RsClsReg.BaseApiResponse):
    RESPONSE_CODE = 'api_login_success'
    RESPONSE_TYPE = 'application/json'

    @staticmethod
    def get_request():
        request = self.__get_request_from_query()
        payload = dict(request.form)
        payload['user_type'] = request.args.get('type', 'customer')
        
        return LoginPayload(**payload)

    @staticmethod
    def get_response(user_token_info: UserTokenInfo) -> str:
        return user_token_info.to_dict()

class CaptchaAPI(Apimixin):
    _serializer_class = ApiCaptchaResponse
    
    @classmethod
    async def get(cls):
        captcha_response_cls = cls._serializer_class
        return await captcha_response_cls.create()

class BaseAPIMixin:
    async def create(self):
        response_obj = self._serializer_class(response_code=200)
        return await response_obj.save_to_db()


@result_serializer_class_registry.register_result_serializer("apilogin.success", "response")
class ApiCaptchaResponse(ResultSerializer):
    
    RESPONSE_TYPE="application/json"
    RESPONSE_CONTENT=None

    async def save_to_db(self):
        self.RESPONSE_CONTENT=CaptchaResponse()

This revision addresses the minor inconsistencies and provides a cleaner implementation structure while maintaining functionality.

29 changes: 28 additions & 1 deletion apps/users/serializers/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
@date:2025/4/14 11:08
@desc:
"""
import base64
import datetime

from captcha.image import ImageCaptcha
from django.core import signing
from django.core.cache import cache
from django.db.models import QuerySet
Expand All @@ -17,13 +19,14 @@
from common.constants.authentication_type import AuthenticationType
from common.constants.cache_version import Cache_Version
from common.exception.app_exception import AppApiException
from common.utils.common import password_encrypt
from common.utils.common import password_encrypt, get_random_chars
from users.models import User


class LoginRequest(serializers.Serializer):
username = serializers.CharField(required=True, max_length=64, help_text=_("Username"), label=_("Username"))
password = serializers.CharField(required=True, max_length=128, label=_("Password"))
captcha = serializers.CharField(required=True, max_length=64, label=_('captcha'))


class LoginResponse(serializers.Serializer):
Expand All @@ -40,6 +43,11 @@ def login(instance):
LoginRequest(data=instance).is_valid(raise_exception=True)
username = instance.get('username')
password = instance.get('password')
captcha = instance.get('captcha')
captcha_cache = cache.get(Cache_Version.CAPTCHA.get_key(captcha=captcha),
version=Cache_Version.CAPTCHA.get_version())
if captcha_cache is None:
raise AppApiException(1005, _("Captcha code error or expiration"))
user = QuerySet(User).filter(username=username, password=password_encrypt(password)).first()
if user is None:
raise AppApiException(500, _('The username or password is incorrect'))
Expand All @@ -52,3 +60,22 @@ def login(instance):
version, get_key = Cache_Version.TOKEN.value
cache.set(get_key(token), user, timeout=datetime.timedelta(seconds=60 * 60 * 2).seconds, version=version)
return {'token': token}


class CaptchaResponse(serializers.Serializer):
"""
登录响应对象
"""
captcha = serializers.CharField(required=True, label=_("captcha"))


class CaptchaSerializer(serializers.Serializer):
@staticmethod
def generate():
chars = get_random_chars()
image = ImageCaptcha()
data = image.generate(chars)
captcha = base64.b64encode(data.getbuffer())
cache.set(Cache_Version.CAPTCHA.get_key(captcha=chars), chars,
timeout=60, version=Cache_Version.CAPTCHA.get_version())
return {'captcha': 'data:image/png;base64,' + captcha.decode()}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here’s an analysis of the provided Python code snippet for the Django project, focusing on correctness and potential improvements:

Irregularities:

  1. Imports: The ImageCaptcha class is used to generate captchas. This function was deprecated in favor of using Pillow (PIL fork) or similar libraries like Pilbox.
  2. String Concatenation: Direct string concatenation using + can lead to SQL injection vulnerabilities when dealing with untrusted input.

Potential Issues:

  1. Database Security: Passwords should not be stored in plain text. Implementing hashing like bcrypt would enhance security.
  2. Code Readability: Repeated logic could be optimized into a reusable function.
  3. Captcha Expiration Logic: Ensure that caching mechanism correctly handles expiration times, even after successful verification.

Optimization Suggestions:

1. Update Imports

Replace the deprecated cryptography.hazmat.primitives.kdf.pbkdf2

from django.core.cache import cache
from django.db.models import QuerySet
from django.http.response import HttpResponse

2. Enhance Password Handling

Encrypt passwords before storing them in the database:

def password_encrypt(raw_password):
    # Use secure way such as bcrypt.hashpw or hashlib.pbkdf2_hmac
    salt=b'SOME_SECURE_SALT'
    hash_object = hashlib.pbkdf2_hmac('sha256', raw_password.encode(), salt, 100000)
    encoded_salt_salted_hash = base64.b64encode(salt + hash_object).decode("utf-8")
    return encoded_salt_salted_hash

3. Simplify Captcha Response Generation

Create a separate method inside captcha serializer for generating the response:

class CaptchaSerializer(serializers.Serializer):
    @staticmethod
    def create_captcha_response(chars):
        image = ImageCaptcha()
        data = image.generate_ascii(characters=chars)
        captcha_bytes = bytes.fromhex(data.hex())
        return {
            "image": captcha_bytes,
            "text": chars
        }

    class Meta:
        fields = ["text"]

Also modify generate() method accordingly:

@staticmethod
def generate():
    chars = get_random_chars()
    result = CaptchaSerializer.create_captcha_response(chars).get("text").replace("\n", "")
    cache.set(Cache_Version.CAPTCHA.get_key(captcha=result), result,
              timeout=60, version=Cache_Version.CAPTCHA.get_version())
    return {"captcha": str(result)}

Modify the login view to use this updated API endpoint:

import json
...

@api_view(['POST'])
def login(request):
    if request.method == 'POST':
        serializer = LoginRequest(data=request.data)
        
        try:
            if not serializer.is_valid(raise_exception=True):
                ...
            
            username = instance.get('username')
            password = instance.get('password')
            captcha = instance.get('captcha')
            if not validate_image_code(captcha):
                
                    raise AppApiException(
                        status=status.HTTP_401_UNAUTHORIZED,
                        detail='Your captcha has been expired.')
                if user is None:
                    ...  
        except Exception as e:
            ...
    
        ...

@login_required(login_url='/login/')
@csrf_exempt
def validate_image_code(image_code_input):
    captcha_cache = cache.get(Cache_Version.CAPTCHA.get_key(captcha=image_code_input))
    
    print(captcha_cache)
    # Validate it here...

This modification ensures proper handling of CSRF tokens, validation logic encapsulated within functions, and improved readability through modularized components. Additionally, make sure you implement checks for CAPTCHA validity within the validate_image_code() function based on current rules set during CAPTCHA generation process.

Remember to replace placeholder values where necessary (e.g., 'SOME_SECURE_SALT', etc.).

1 change: 1 addition & 0 deletions apps/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
urlpatterns = [
path('user/login', views.LoginView.as_view(), name='login'),
path('user/profile', views.UserProfileView.as_view(), name="user_profile"),
path('user/captcha', views.CaptchaView.as_view(), name='captcha'),
path('user/test', views.TestPermissionsUserView.as_view(), name="test"),
path('workspace/<str:workspace_id>/user/profile', views.TestWorkspacePermissionUserView.as_view(),
name="test_workspace_id_permission")
Expand Down
14 changes: 12 additions & 2 deletions apps/users/views/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from rest_framework.views import APIView

from common import result
from users.api.login import LoginAPI
from users.serializers.login import LoginSerializer
from users.api.login import LoginAPI, CaptchaAPI
from users.serializers.login import LoginSerializer, CaptchaSerializer


class LoginView(APIView):
Expand All @@ -25,3 +25,13 @@ class LoginView(APIView):
responses=LoginAPI.get_response())
def post(self, request: Request):
return result.success(LoginSerializer().login(request.data))


class CaptchaView(APIView):
@extend_schema(methods=['GET'],
description=_("Get captcha"),
operation_id=_("Get captcha"),
tags=[_("User management")],
responses=CaptchaAPI.get_response())
def get(self, request: Request):
return result.success(CaptchaSerializer().generate())
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no significant irregularities, potential issues, or optimization suggestions in this code. The change is minor with adding a new CaptchaView that handles GET requests to generate captchas. This addition maintains clarity in handling two distinct functionalities within user authentication (logging in and obtaining a captcha).

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ psycopg = { extras = ["binary"], version = "3.2.6" }
python-dotenv = "1.1.0"
uuid-utils = "0.10.0"
diskcache2 = "0.1.2"
captcha = "0.7.1"
langchain-openai = "^0.3.0"
langchain-anthropic = "^0.3.0"
langchain-community = "^0.3.0"
Expand Down
Loading