Skip to content
New issue

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

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

Already on GitHub? # to your account

[BUG] Django Ninja JWT Token Validation Issue #117

Open
subham1099 opened this issue Feb 14, 2025 · 2 comments
Open

[BUG] Django Ninja JWT Token Validation Issue #117

subham1099 opened this issue Feb 14, 2025 · 2 comments

Comments

@subham1099
Copy link

subham1099 commented Feb 14, 2025

Description

When using Django Ninja JWT with a custom token obtain pair schema, the validation is being bypassed due to input type mismatch, leading to authentication errors.

Environment

  • Python: 3.12
  • Django: 5.1.6
  • django-ninja: 1.3.0
  • django-ninja-jwt: 5.3.5
  • pydantic: 2.9.2

Issue

The TokenObtainInputSchemaBase.validate_inputs method expects the input to be a dictionary, but in the current version of Django Ninja, the input is wrapped in a DjangoGetter object. This causes the validation to be bypassed, leading to a NoneType error when trying to authenticate.

Code

class TokenObtainPairInputSchema(TokenObtainInputSchemaBase):
      """Custom schema for token obtain pair."""
      @pyd.model_validator(mode="before")
      def validate_inputs(cls, values: DjangoGetter) -> DjangoGetter:
          input_values = values.obj
          request = values.context.get("request")
          # This condition is never true because input_values is a DjangoGetter
          if isinstance(input_values, dict):  # <--
              values.obj.update(
                  cls.validate_values(request=request, values=input_values)
              )
              return values
          return values

    @classmethod
    def get_response_schema(cls) -> type[Schema]:
        return TokenObtainPairOutputSchema

    @classmethod
    def get_token(cls, user: AbstractUser) -> dict[str, t.Any]:
        values = {}
        refresh = RefreshToken.for_user(user)
        values["refresh"] = str(refresh)
        values["access"] = str(refresh.access_token)
        values.update(
            user=UserSchema.from_orm(user)
        )  # this will be needed when creating output schema
        return values

Request

curl -X 'POST' \
'http://localhost:8001/api/v1/auth/token/pair' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"username": "string",
"password": "string"
}'

Error Log

[debug ] Input validation - values type: <class 'ninja.schema.DjangoGetter'>
[debug ] Input validation - input_values type: <class 'ninja.schema.DjangoGetter'>
[debug ] Input validation - input_values: <DjangoGetter: {'password': 'string', 'username': 'string'}>
[error ] 'NoneType' object has no attribute 'id'

Traceback (most recent call last):
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja/operation.py", line 119, in run
    values = self._get_values(request, kw, temporal_response)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja/operation.py", line 288, in _get_values
    data = model.resolve(request, self.api, path_params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja/params/models.py", line 57, in resolve
    return cls.model_validate(data, context={"request": request})
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/pydantic/main.py", line 641, in model_validate
    return cls.__pydantic_validator__.validate_python(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja/schema.py", line 228, in _run_root_validator
    return handler(values)
           ^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja_jwt/schema.py", line 117, in post_validate
    return cls.post_validate_schema(values)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja_jwt/schema.py", line 128, in post_validate_schema
    data = cls.get_token(cls._user)
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/src/app/app/api/auth/token_obtain.py", line 93, in get_token
    refresh = RefreshToken.for_user(user)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja_jwt/tokens.py", line 189, in for_user
    user_id = getattr(user, api_settings.USER_ID_FIELD)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'id'

Expected Behavior

The validation should handle both dictionary and DjangoGetter inputs, ensuring proper validation before authentication attempts.

Current Workaround

We've implemented a workaround by explicitly handling the DjangoGetter case:

@pyd.model_validator(mode="before")
def validate_inputs(cls, values: DjangoGetter) -> DjangoGetter:
    input_values = values.obj
    request = values.context.get("request")
    # Handle both dict and DjangoGetter inputs
    if isinstance(input_values, dict):
        values_dict = input_values
    else:
        # Convert DjangoGetter to dict
        values_dict = input_values._obj

    validated_values = cls.validate_values(request=request, values=values_dict)
    values.obj = validated_values
    return values

Questions

  1. Is this the intended behavior of the input validation?
  2. Should the base implementation be updated to handle DjangoGetter inputs?
  3. Is there a better way to handle this validation in custom schemas?
@subham1099
Copy link
Author

subham1099 commented Feb 14, 2025

For more context, I have also tried to use the original ninja-jwt's implementation without any customization, same issue.

following is how I add the router to ninja api:

from ninja import Router
from ninja_jwt.routers.obtain import obtain_pair_router


router = Router(tags=["auth"])

# This ninja_jwt router contains two endpoints:
#   - /pair: Obtain a pair of access and refresh tokens
#   - /refresh: Refresh an access token
router.add_router("/token", obtain_pair_router, auth=None, tags=["token"])

@subham1099
Copy link
Author

I also found out that setting model_config = pydantic.ConfigDict(...) also breaks the implementation. Example schema:

class TokenObtainPairInputSchema(TokenObtainInputSchemaBase):
    """Custom schema for token obtain pair.

    NOTE: this schema is used to customize the output schema of the token obtain pair.
    This is set in the project's settings.py file.
    """

    model_config = pyd.ConfigDict(extra="forbid")

    @classmethod
    def get_response_schema(cls) -> type[SchemaOut]:
        return TokenObtainPairOutputSchema

    @classmethod
    def get_token(cls, user: AbstractUser) -> dict[str, t.Any]:
        values = {}
        refresh = RefreshToken.for_user(user)
        values["refresh"] = str(refresh)
        values["access"] = str(refresh.access_token)
        values.update(
            user=UserSchema.from_orm(user)
        )  # this will be needed when creating output schema
        return values

results in - AttributeError: 'dict' object has no attribute '_obj' :

╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/operation.py:119 in run                                                                        │
│                                                                                                  │
│   116 │   │   │   return error                                                                   │
│   117 │   │   try:                                                                               │
│   118 │   │   │   temporal_response = self.api.create_temporal_response(request)                 │
│ ❱ 119 │   │   │   values = self._get_values(request, kw, temporal_response)                      │
│   120 │   │   │   result = self.view_func(request, **values)                                     │
│   121 │   │   │   return self._result_to_response(request, result, temporal_response)            │
│   122 │   │   except Exception as e:                                                             │
│                                                                                                  │
│ ╭─────────────────────────────────────── locals ────────────────────────────────────────╮        │
│ │                 e = AttributeError("'dict' object has no attribute '_obj'")           │        │
│ │             error = None                                                              │        │
│ │                kw = {}                                                                │        │
│ │           request = <WSGIRequest: POST '/api/v1/auth/token/pair'>                     │        │
│ │              self = <ninja.operation.Operation object at 0xffffa30d6db0>              │        │
│ │ temporal_response = <HttpResponse status_code=200, "application/json; charset=utf-8"> │        │
│ ╰───────────────────────────────────────────────────────────────────────────────────────╯        │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/operation.py:288 in _get_values                                                                │
│                                                                                                  │
│   285 │   │   values, errors = {}, []                                                            │
│   286 │   │   for model in self.models:                                                          │
│   287 │   │   │   try:                                                                           │
│ ❱ 288 │   │   │   │   data = model.resolve(request, self.api, path_params)                       │
│   289 │   │   │   │   values.update(data)                                                        │
│   290 │   │   │   except pydantic.ValidationError as e:                                          │
│   291 │   │   │   │   items = []                                                                 │
│                                                                                                  │
│ ╭─────────────────────────────────────── locals ────────────────────────────────────────╮        │
│ │            errors = []                                                                │        │
│ │       path_params = {}                                                                │        │
│ │           request = <WSGIRequest: POST '/api/v1/auth/token/pair'>                     │        │
│ │              self = <ninja.operation.Operation object at 0xffffa30d6db0>              │        │
│ │ temporal_response = <HttpResponse status_code=200, "application/json; charset=utf-8"> │        │
│ │            values = {}                                                                │        │
│ ╰───────────────────────────────────────────────────────────────────────────────────────╯        │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/params/models.py:57 in resolve                                                                 │
│                                                                                                  │
│    54 │   │   │   return cls()                                                                   │
│    55 │   │                                                                                      │
│    56 │   │   data = cls._map_data_paths(data)                                                   │
│ ❱  57 │   │   return cls.model_validate(data, context={"request": request})                      │
│    58 │                                                                                          │
│    59 │   @classmethod                                                                           │
│    60 │   def _map_data_paths(cls, data: DictStrAny) -> DictStrAny:                              │
│                                                                                                  │
│ ╭────────────────────────────────────────── locals ──────────────────────────────────────────╮   │
│ │         api = <ninja_extra.main.NinjaExtraAPI object at 0xffffa32d7f50>                    │   │
│ │        data = {'user_token': {'username': 'user0@example.com', 'password': 'testpass123'}} │   │
│ │ path_params = {}                                                                           │   │
│ │     request = <WSGIRequest: POST '/api/v1/auth/token/pair'>                                │   │
│ ╰────────────────────────────────────────────────────────────────────────────────────────────╯   │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/pyda │
│ ntic/main.py:641 in model_validate                                                               │
│                                                                                                  │
│    638 │   │   """                                                                               │
│    639 │   │   # `__tracebackhide__` tells pytest and some other tools to omit this function fr  │
│    640 │   │   __tracebackhide__ = True                                                          │
│ ❱  641 │   │   return cls.__pydantic_validator__.validate_python(                                │
│    642 │   │   │   obj, strict=strict, from_attributes=from_attributes, context=context          │
│    643 │   │   )                                                                                 │
│    644                                                                                           │
│                                                                                                  │
│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │
│ │         context = {'request': <WSGIRequest: POST '/api/v1/auth/token/pair'>}                 │ │
│ │ from_attributes = None                                                                       │ │
│ │             obj = {                                                                          │ │
│ │                   │   'user_token': {                                                        │ │
│ │                   │   │   'username': 'user0@example.com',                                   │ │
│ │                   │   │   'password': 'testpass123'                                          │ │
│ │                   │   }                                                                      │ │
│ │                   }                                                                          │ │
│ │          strict = None                                                                       │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/schema.py:225 in _run_root_validator                                                           │
│                                                                                                  │
│   222 │   │   forbids_extra = cls.model_config.get("extra") == "forbid"                          │
│   223 │   │   should_validate_assignment = cls.model_config.get("validate_assignment", False)    │
│   224 │   │   if forbids_extra or should_validate_assignment:                                    │
│ ❱ 225 │   │   │   handler(values)                                                                │
│   226 │   │                                                                                      │
│   227 │   │   values = DjangoGetter(values, cls, info.context)                                   │
│   228 │   │   return handler(values)                                                             │
│                                                                                                  │
│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │
│ │              forbids_extra = True                                                            │ │
│ │                    handler = ValidatorCallable(Prebuilt(PrebuiltValidator {                  │ │
│ │                              schema_validator: Py(0xaaaafd71c3d0) }))                        │ │
│ │                       info = ValidationInfo(config={'title': 'BodyParams'},                  │ │
│ │                              context={'request': <WSGIRequest: POST                          │ │
│ │                              '/api/v1/auth/token/pair'>}, data={}, field_name=None)          │ │
│ │ should_validate_assignment = False                                                           │ │
│ │                     values = {'username': 'user0@example.com', 'password': 'testpass123'}    │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/schema.py:225 in _run_root_validator                                                           │
│                                                                                                  │
│   222 │   │   forbids_extra = cls.model_config.get("extra") == "forbid"                          │
│   223 │   │   should_validate_assignment = cls.model_config.get("validate_assignment", False)    │
│   224 │   │   if forbids_extra or should_validate_assignment:                                    │
│ ❱ 225 │   │   │   handler(values)                                                                │
│   226 │   │                                                                                      │
│   227 │   │   values = DjangoGetter(values, cls, info.context)                                   │
│   228 │   │   return handler(values)                                                             │
│                                                                                                  │
│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │
│ │              forbids_extra = True                                                            │ │
│ │                    handler = ValidatorCallable(Model(ModelValidator { revalidate: Never,     │ │
│ │                              validator: FunctionBefore(FunctionBeforeValidator { validator:  │ │
│ │                              ModelFields(ModelFieldsValidator { fields: [Field { name:       │ │
│ │                              "password", lookup_key: Simple { key: "password", py_key:       │ │
│ │                              Py(0xffffa2cddf30), path: LookupPath([S("password",             │ │
│ │                              Py(0xffffa2cde0f0))]) }, name_py: Py(0xffffbbb8a308),           │ │
│ │                              validator: StrConstrained(StrConstrainedValidator { strict:     │ │
│ │                              false, pattern: None, max_length: Some(128), min_length: None,  │ │
│ │                              strip_whitespace: false, to_lower: false, to_upper: false,      │ │
│ │                              coerce_numbers_to_str: false }), frozen: false }, Field { name: │ │
│ │                              "username", lookup_key: Simple { key: "username", py_key:       │ │
│ │                              Py(0xffffa2cddef0), path: LookupPath([S("username",             │ │
│ │                              Py(0xffffa2cdde30))]) }, name_py: Py(0xffffbafd23f0),           │ │
│ │                              validator: StrConstrained(StrConstrainedValidator { strict:     │ │
│ │                              false, pattern: None, max_length: Some(150), min_length: None,  │ │
│ │                              strip_whitespace: false, to_lower: false, to_upper: false,      │ │
│ │                              coerce_numbers_to_str: false }), frozen: false }], model_name:  │ │
│ │                              "TokenObtainPairInputSchema", extra_behavior: Forbid,           │ │
│ │                              extras_validator: None, strict: false, from_attributes: true,   │ │
│ │                              loc_by_alias: true }), func: Py(0xffffa2cdd500), config:        │ │
│ │                              Py(0xffffa2cde180), name: "function-before[validate_inputs(),   │ │
│ │                              model-fields]", field_name: None, info_arg: false }), class:    │ │
│ │                              Py(0xaaaafd71b480), generic_origin: None, post_init: None,      │ │
│ │                              frozen: false, custom_init: false, root_model: false,           │ │
│ │                              undefined: Py(0xffffb6b35c60), name:                            │ │
│ │                              "TokenObtainPairInputSchema" }))                                │ │
│ │                       info = ValidationInfo(config={'title': 'TokenObtainPairInputSchema',   │ │
│ │                              'extra_fields_behavior': 'forbid', 'from_attributes': True},    │ │
│ │                              context={'request': <WSGIRequest: POST                          │ │
│ │                              '/api/v1/auth/token/pair'>}, data={}, field_name=None)          │ │
│ │ should_validate_assignment = False                                                           │ │
│ │                     values = {'username': 'user0@example.com', 'password': 'testpass123'}    │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a_jwt/schema.py:106 in validate_inputs                                                           │
│                                                                                                  │
│   103 │                                                                                          │
│   104 │   @model_validator(mode="before")                                                        │
│   105 │   def validate_inputs(cls, values: DjangoGetter) -> DjangoGetter:                        │
│ ❱ 106 │   │   input_values = values._obj                                                         │
│   107 │   │   request = values._context.get("request")                                           │
│   108 │   │   if isinstance(input_values, dict):                                                 │
│   109 │   │   │   values._obj.update(                                                            │
│                                                                                                  │
│ ╭─────────────────────────────── locals ────────────────────────────────╮                        │
│ │ values = {'username': 'user0@example.com', 'password': 'testpass123'} │                        │
│ ╰───────────────────────────────────────────────────────────────────────╯                        │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
AttributeError: 'dict' object has no attribute '_obj'

@subham1099 subham1099 changed the title Django Ninja JWT Token Validation Issue ( [BUG] Django Ninja JWT Token Validation Issue Feb 14, 2025
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant