django-ninja icon indicating copy to clipboard operation
django-ninja copied to clipboard

JWT authentication

Open baxeico opened this issue 4 years ago • 18 comments

Hi @vitalik , first of all congrats for your work in this project! I really like it. I've a simple question, sorry if this is a trivial one! ;) In Django Rest Framework we can integrate JWT authentication quite easily using this https://github.com/SimpleJWT/django-rest-framework-simplejwt.

Is there something similar for django-ninja? I saw that in another issue (https://github.com/vitalik/django-ninja/issues/9) you mentioned JWT, but I don't understand how it could be done in practice.

Thank you very much for your help and keep up the good work!

baxeico avatar Dec 04 '20 15:12 baxeico

it is not out of the box, but (as it actually requested few more time) I will try to pull out some example into separate repository as a plugin

vitalik avatar Dec 04 '20 16:12 vitalik

Hi @vitalik!

Any news?

bondarev avatar May 19 '21 23:05 bondarev

I was having the same question... Since JWT is just another form of bearer authentication what about this? I'm using python-jose for the JWT.

from jose import jwt

[...]
class JWT(HttpBearer):
    def authenticate(self, request, token):
        try:
            jwt.decode(token, 'key')
            return True
        except:
            return False
[...]

@router.get('/', auth=JWT())

This works just fine.

EDIT: Somehow it only worked when using Django's development server and didn't translate to the productive environment. God knows why but it's already too late for me to think properly. I fiddled around with it even more, another solution at least within a development setting is:


def validate_jwt(request):
    token = request.META['HTTP_AUTHORIZATION'].split(" ")[1]
    try:
        jwt.decode(token, 'key')
        return True
    except:
        return False
[...]
@router.get('/', auth=validate_jwt)

But this also sucks. I will look into it tomorrow.

EDIT 2: Seems like I don't get the standard AuthBearer way to work either (following the tutorial).

ghost avatar Jul 20 '21 21:07 ghost

I don't know if there is a bug or not, but I could make it work with the ApiKey(APIKeyHeader) method in my production environment. I contaminated every second line of my script with loggers, and for the AuthBearer method, it seems like the authentication function is never called. Has anyone experienced a similar thing?

ghost avatar Jul 21 '21 16:07 ghost

@fantasticle nope, there is a chance you have some typo :) without a code could not help

vitalik avatar Jul 22 '21 06:07 vitalik

I have been able to integrate it easily in a few places, initially i followed the GlobalAuth example in the docs here by extending the HttpBearer class like so and using the JWTAuthentication class from the simplejwt library, like so:

from typing import Any, Optional
from django.http import HttpRequest
from ninja.security import HttpBearer
from rest_framework_simplejwt.authentication import JWTAuthentication


class JWTAuthRequired(HttpBearer):
    def authenticate(self, request: HttpRequest, token: str) -> Optional[Any]:
        jwt_authenticator = JWTAuthentication()
        try:
            response = jwt_authenticator.authenticate(request)
            if response is not None:
                return True # 200 OK
            return False # 401
        except Exception:
            # Any exception we want it to return False i.e 401
            return False

As for the creation of tokens, its a fairly simple approach also. I have 2 schemas one for the auth and one for the repsonse:


from ninja import Schema


class AuthSchema(Schema):
    username: str
    password: str


class JWTPairSchema(Schema):
    refresh: str
    access: str

These are then used in my authentication router like so:

from ninja import Router
from auth.api.schema import AuthSchema, JWTPairSchema
from django.contrib.auth import authenticate
from rest_framework_simplejwt.tokens import RefreshToken


router = Router()


@router.post('/login', response=JWTPairSchema, auth=None)
def login(request, auth: AuthSchema):
    user = authenticate(**auth.dict())
    if user is not None:
        refresh = RefreshToken.for_user(user)

    return {
        'refresh': str(refresh),
        'access': str(refresh.access_token),
    }

I have reused all my authentication logic from DRF in django-ninja and it works well. Hope that helps someone else.

bencleary avatar Feb 24 '22 12:02 bencleary

Hi, found this post to implement auth https://www.reddit.com/r/django/comments/r2tti8/django_ninja_auth_example/ But that did not work for me.

VetalM84 avatar Nov 29 '22 09:11 VetalM84

@VetalM84,

@bencleary's comment above worked for me. However, it's less than ideal and isn't something I want to go to production with since it installs the Django Rest Framework because it is a dependency of djangorestframework-simplejwt.

I'm curious, what didn't work in the example you linked to? I think the solution you linked to since it uses PyJWT.

P.S. your link didn't work when I clicked on it. I had to copy and paste it.

For the project I'm working on, I'm also considering going back to just using django_auth and coming up with a system for the front-end to use csrf tokens.

epicserve avatar Nov 29 '22 13:11 epicserve

I'm curious, what didn't work in the example you linked to?

I've got the working one based on that!

def create_token(username):
    jwt_signing_key = getattr(settings, "JWT_SIGNING_KEY", None)
    jwt_access_expire = getattr(settings, "JWT_ACCESS_EXPIRY", 60)
    payload = {"username": username}
    access_expire = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(
        minutes=jwt_access_expire
    )
    payload.update({"exp": access_expire})
    token = jwt.encode(payload=payload, key=jwt_signing_key, algorithm="HS256")
    return token


class AuthBearer(HttpBearer):
    def authenticate(self, request, token):
        jwt_signing_key = getattr(settings, "JWT_SIGNING_KEY", None)
        try:
            payload = jwt.decode(token, key=jwt_signing_key, algorithms=["HS256"])
        except Exception as e:
            return {"error": e}
        username: str = payload.get("username", None)
        return username


@api.post("/sign_in", auth=None)
def sign_in(request, username: str = Form(...), password: str = Form(...)):
    user_model = get_object_or_404(User, username=username)

    passwords_match = check_password(password, user_model.password)
    if not passwords_match:
        raise ValidationError([{"error": "Wrong password"}])

    token = create_token(user_model.username)
    return {"token": token}

VetalM84 avatar Nov 29 '22 17:11 VetalM84

@VetalM84 Hey, I'm the author of that comment in the reddit thread. As far as I can tell you managed to get it to work now, but if you have any more problems let me know and I'll try to help. I am building a pretty large enterprise app and this auth method has been happily working for me for a year now.

ognjenk avatar Nov 30 '22 07:11 ognjenk

@ognjenk How I can unittest sign in endpoint?

    def test_sign_in(self):
        """Test Sing in."""
        data = {
            "username": "TestUserName",
            "password": "test"
        }
        response = self.client.post(
            path="/api/sign_in",
            data=data,
            content_type="application/x-www-form-urlencoded",
            follow=True
        )
        self.assertEqual(response.status_code, 200)

This code returns me AssertionError: 422 != 200

VetalM84 avatar Dec 01 '22 09:12 VetalM84

Hey, if you have set up your sign in endpoint to send urlencoded data (and set up parameters as form data with something like username: str = Form(...), password: str = Form(...) than you would have to set up your test something like this:

data = f"username={username}&password={password}"
response = client.post(f"{API_ROOT}/auth/sign_in", data=data, content_type="application/x-www-form-urlencoded")

I have since switched my sign in endpoint to expect the usual json post request where I'm sending username and password in json, so then the test would be like yours, only content_type would be = "application/json"

ognjenk avatar Dec 01 '22 13:12 ognjenk

@ognjenk Thank you. Although I've found one more easy solution:

...
username: str = Form(...), password: str = Form(...)
...
    def test_sign_in(self):
        """Test Sing in."""
        data = {"username": "TestUserName", "password": "test"}
        response = self.client.post(
            path="/api/sign_in",
            data=data,
            # or like this
            # data=f"username=TestUserName&password=test",
            # content_type="application/x-www-form-urlencoded",
        )
        self.assertEqual(response.status_code, 200)

I just had to remove this line content_type="application/x-www-form-urlencoded" and it worked like I did a json request.

I wonder do you user roles (Django user groups) to split access to different endpoints?

VetalM84 avatar Dec 01 '22 16:12 VetalM84

No, we have rolled out our own solution for endpoint authorization. We cache it in Redis per user and have created a custom decorator which we use for endpoints that need the check.

ognjenk avatar Dec 02 '22 16:12 ognjenk

Is it secure to have a static "JWT_SIGNING_KEY" like that?

bendowlingtech avatar Jan 13 '23 15:01 bendowlingtech