MasoniteFramework / masonite

The Modern And Developer Centric Python Web Framework. Be sure to read the documentation and join the Discord channel for questions: https://discord.gg/TwKeFahmPZ
http://docs.masoniteproject.com
MIT License
2.2k stars 127 forks source link

Support for SPA/RESTful APIs (with proper JWT Authentication) #359

Closed nicolaipre closed 2 years ago

nicolaipre commented 3 years ago

Hi.

A few weeks back I moved over to this project from Laravel, since I wanted to start writing my project backends in Python. I have also previously used Flask and FastAPI but did not like the way those libraries structure web applications. This project however does it in a very clean way and is one of the reasons why I like it and want to use it.

However, I have spent countless hours now trying to figure out how to get proper JWT authentication for my Single Page Application, where I will not use views. It is not starting to get tiring trying to get the framework to work the way I want it to and it's putting my development to a halt.

What I am trying to achieve, is simply getting a working and stable web application with the following routes:

ROUTES = [

    # Server Side Rendered routes - WILL BE HIDDEN CLIENT SIDE

    RouteGroup(
        prefix="/ssr",
        routes=[
            GET('/test',           'HomeController@show').name('ssr.test'),
            POST('/login',         'JWTAuthController@login').name('ssr.login'),
            POST('/register',      'JWTAuthController@register').name('ssr.register'),
        ]
    ),
    RouteGroup(
        prefix="/ssr",
        middleware=['jwt'], # Requires a valid Authorization Bearer Token (JWT)
        routes=[
            POST('/logout',        'JWTAuthController@logout').name('ssr.logout'),
            POST('/refresh',       'JWTAuthController@refresh').name('ssr.refresh'),
            GET('/profile',        'JWTAuthController@me').name('ssr.profile'),
            GET('/getkey',         'APIKeyController@show').name('ssr.getkey'),
            GET('/search/data',    'DataController@show').name('ssr.data.search'),
        ]
    ),

    # Public facing API - should only be accessible with an API KEY generated from users profile, and NOT JWT.
    RouteGroup( 
        prefix="/v1",
        middleware=['apikey'], # Requires a valid X-API-KEY header.
        routes=[
            GET('/',             'HomeController@show').name('api.usage'),
            GET('/search/data',  'DataController@show').name('api.data.search'),
        ]
    ),

It would be amazing if something like this could be crafted by doing craft auth:jwt for JWT authentication and craft auth:apikey for API Key authentication.

nicolaipre commented 3 years ago

I have almost got this working, and will happily share what I have once it works properly.

The problem right now is that exceptions do not trigger HTTP responses and return a 500 error when using authentication middleware on Controller Routes instead of Resource Routes extending the JWTAuthentication class.

josephmancuso commented 3 years ago

not entirely sure I understand the issue. You want a custom HTTP response based on the exception thrown?

josephmancuso commented 3 years ago

all exceptions should result in that 500 page and anytime you send Content-Type: application/json it will render that response as JSON

nicolaipre commented 3 years ago

Thank you for clarifying that.

According to what you explained above, the exceptions defined here should return a response rendered as JSON containing the error of the exception that occurred, as long as Content-Type: application/json is set in the request.

The problem here seems to be that even though Content-Type: application/json is specified in the request, I still get the 500 error and not a rendered response.

For example, the following request returns ERROR:root:NoApiTokenFound in /home/user/backend/app/http/middleware/JWTAuthMiddleware.py on line 19 (500 Internal server error) when it should have returned {'error': 'no API token found'}.

image

Everything works fine when a valid token is set, but whenever exceptions occur I get stuck at the 500 error instead of a rendered response.

Relevant routes

    # Protected SSR routes. Requires valid JWT.
    RouteGroup(
        prefix="/jwt",
        middleware=['jwt'],
        routes=[
            ...
            GET('/profile',        'JWTAuthController@test').name('jwt.profile'),
            ...
        ]
    ),

middleware.py

...
ROUTE_MIDDLEWARE = {
    'auth': AuthenticationMiddleware,
    'verified': VerifyEmailMiddleware,
    'guard': GuardMiddleware,

    # Custom auth middlewares
    'jwt': JWTAuthMiddleware,
    'apikey': APIKeyMiddleware,
}
...

JWTAuthMiddleware.py

"""Authentication Middleware."""

from masonite.request import Request
from masonite.api.authentication import JWTAuthentication, PermissionScopes

class JWTAuthMiddleware(JWTAuthentication):
    """Middleware To Check If a request contains a valid JWT token."""

    def __init__(self, request: Request):
        """Inject Any Dependencies From The Service Container.

        Arguments:
            request {masonite.request.Request} -- The Masonite request class.
        """
        self.request = request

    def before(self):
        """Run This Middleware Before The Route Executes."""
        self.authenticate(self.request)
        return self.request

    def after(self):
        """Run This Middleware After The Route Executes."""
        pass

JWTAuthController.py

"""A AuthController Module."""

import jwt
import pendulum
from config.application import KEY
from masonite.auth import Auth, Sign, MustVerifyEmail
from masonite.request import Request
from masonite.controllers import Controller
from masonite.api.controllers import TokenController
from masonite.validation import Validator
from masonite.managers import MailManager

class JWTAuthController(Controller, TokenController):
    """AuthController Controller Class."""

    def login(self, request: Request, auth: Auth):
        return self.jwt(request, auth)

    def logout(self, request: Request, auth: Auth):
        # TODO: Implement logout
        pass

    def refresh(self, request: Request):
        return self.jwt_refresh(request)

    def register(self, request: Request, auth: Auth, mail_manager: MailManager, validate: Validator):
        if not request.input('username') or not request.input('password') or not request.input('email'):
            request.status(401)
            return {'error': 'missing username, email or password.'}

        errors = request.validate(
                validate.required(["username", "email", "password"]),
                validate.email("email"),
                validate.strong(
                    "password",
                    length=8,
                    special=1,
                    uppercase=1,
                    # breach=True checks if the password has been breached before.
                    # Requires 'pip install pwnedapi'
                    breach=False,
                ),
            )

        if errors:
            return {'error': f'{errors}'}

        try:
            user = auth.register({
                "name": request.input('username'),
                "password": request.input('password'),
                "email": request.input('email'),
            })
        except:
            # FIXME: Check if user already exists with given name or email.
            return {"error": "Registration failed upon creating user."}

        if isinstance(user, MustVerifyEmail):
            user.verify_email(mail_manager, request)

        if user:
            return {'message': 'Registration successful.'}

        return {'error': 'Registration failed.'}

    def me(self, request: Request, auth: Auth):
        return {'message': 'jwt_token["user"]'}

    def test(self):
        return {"message": "hello"}

This is also the same problem I addressed in MasoniteFramework/api#18.

josephmancuso commented 3 years ago

Here is where the logic happens.

https://github.com/MasoniteFramework/masonite/blob/cb6d0d8333c648436425b6fc40f165af36c6a41e/src/masonite/providers/StatusCodeProvider.py#L43

I think I see whats going on. In the middleware to set a response you need to do something like:

"""Authentication Middleware."""

from masonite.request import Request
from masonite.response import Response
from masonite.api.authentication import JWTAuthentication, PermissionScopes

class JWTAuthMiddleware(JWTAuthentication):
    """Middleware To Check If a request contains a valid JWT token."""

    def __init__(self, request: Request, response: Response):
        """Inject Any Dependencies From The Service Container.

        Arguments:
            request {masonite.request.Request} -- The Masonite request class.
        """
        self.request = request
        self.response = response

    def before(self):
        """Run This Middleware Before The Route Executes."""
        if not self.authenticate(self.request):
            self.response.json({'error': 'error message here'}, status=500)

    def after(self):
        """Run This Middleware After The Route Executes."""
        pass

Something like that

nicolaipre commented 3 years ago

I attempted to apply this fix but unfortunately, the same issue still occurs. I have checked the headers that are being sent and verified that they are passed but in the "environ" part of the request. Could it be related to the header rewriting, where "HTTP_" is appended to certain headers? When printing the request in the middleware, I noticed that sending Content-Type is being stored as environ.CONTENT_TYPE, and not HTTP_CONTENT_TYPE.

No idea if this is relevant though.

Update: Also, the JWTAuthentication method from MasoniteFramework/api does not really return anything, so not sure if that if not self.authenticate(self.request) is useful at all?

Update 2: Everything works fine when a Resource is requested and is used with masonite.api.providers import ApiProvider. Maybe it is related to how resource routes are handled compared to controller routes?

Relevant routes

...
AdminUserResource('/users').routes(),
...

AdminUserResource.py

from app.User import User
from masonite.request import Request
from masonite.api.resources import Resource
from masonite.api.serializers import JSONSerializer
from masonite.api.authentication import JWTAuthentication, PermissionScopes, TokenAuthentication
from masonite.api.filters import FilterScopes

#, TokenAuthentication): # JWTAuthentication, PermissionScopes, FilterScopes)
class AdminUserResource(Resource, JSONSerializer, JWTAuthentication): 
    model = User
    #methods = ['create', 'index', 'show']
    #without = ['id', 'email', 'password']
    #scopes = ['user:read']
    """
    filter_scopes = {
        'user:read': ['name', 'email'],
        'user:manager': ['id', 'name', 'email', 'active', 'password']
    }
    """

    def show(self, request: Request):
        return self.model.where('active', self.request.input('active')).get()

    def index(self):
        return self.model.all()
nicolaipre commented 3 years ago

Also, is middleware the correct way to handle what I am trying to achieve with protected routes, or should I use guards for this instead? I am not that familiar with the functionality of guards and how they differ from middleware just yet.

josephmancuso commented 3 years ago

Ok in gonna take a look at this myself and see what the issue is. It should be working as is

josephmancuso commented 3 years ago

@nicolaipre Ok I made a middleware like this. It actually wasn't functioning exactly like I thought it would. I'm planning on rewriting how this header stuff works internally in Masonite 3.

With that being said you might have to do something like this:

"""Authentication Middleware."""

from masonite.request import Request
from masonite.response import Response

class JWTAuthMiddleware:
    """Middleware To Check If The User Is Logged In."""

    def __init__(self, request: Request, response: Response):
        """Inject Any Dependencies From The Service Container.

        Arguments:
            request {masonite.request.Request} -- The Masonite request class.
        """
        self.request = request
        self.response = response

    def before(self):
        """Run This Middleware Before The Route Executes."""
        if self.request.header('Content-Type') == "application/json":
            self.response.json({"error": "wrong"}, status=500)

    def after(self):
        """Run This Middleware After The Route Executes."""
        pass
josephmancuso commented 3 years ago
Screen Shot 2020-10-27 at 10 16 57 PM
nicolaipre commented 3 years ago

Thank you for the fix in PR#360.

I just spent the last two hours debugging and writing a massive reply here, but all of a sudden I managed to get it working with your help! :+1: Very happy with that.

I solved the problem by rewriting the Middleware and creating a JWTAuthProvider class that extends MasoniteFramework/api's JWTAuthentication.

The problem was that something was being done under the hood since error messages were being returned perfectly fine with Resources, but not with my JWTAuthMiddleware. It seemed that BaseAuthentication.run_authentication never got executed even though the Middelware extended JWTAuthentication and I had to handle exceptions similar to this.

JWTAuthMiddleware.py

"""JWT Authentication Middleware."""

from masonite.request import Request
from masonite.response import Response
from masonite.api.authentication import JWTAuthentication, PermissionScopes
from ...providers.JWTAuthProvider import JWTAuthProvider

class JWTAuthMiddleware(JWTAuthProvider):
    """Middleware To Check If a Request Contains a Valid JWT Token."""

    def __init__(self, request: Request, response: Response):
        """Inject Any Dependencies From The Service Container.

        Arguments:
            request {masonite.request.Request} -- The Masonite request class.
            response {masonite.response.Response} -- The Masonite response class.
        """
        self.request = request
        self.response = response

    def before(self):
        """Run This Middleware Before The Route Executes."""
        self.check_jwt(self.request)

    def after(self):
        """Run This Middleware After The Route Executes."""
        pass

JWTAuthProvider.py


import pendulum
from masonite.request import Request
from masonite.api.exceptions import NoApiTokenFound, ExpiredToken, InvalidToken
from masonite.api.authentication import JWTAuthentication, PermissionScopes

from masonite.api.exceptions import (ApiNotAuthenticated, ExpiredToken, InvalidToken,
                          NoApiTokenFound, PermissionScopeDenied,
                          RateLimitReached)

class JWTAuthProvider(JWTAuthentication):

    def check_jwt(self, request: Request):
        """ Valdate the JWT token specified in Request """
        try:
            self.authenticate(self.request)
        except ApiNotAuthenticated:
            return self.response.json({'error': 'token not authenticated'}, status=500) # TODO: Change status codes...
        except ExpiredToken:
            return self.response.json({'error': 'token has expired'}, status=500)
        except InvalidToken:
            return self.response.json({'error': 'token is invalid'}, status=500)
        except NoApiTokenFound:
            return self.response.json({'error': 'no API token found'}, status=500)
        except PermissionScopeDenied:
            return self.response.json({'error': 'token has invalid scope permissions'}, status=500)
        except RateLimitReached:
            return self.response.json({'error': 'rate limit reached'}, status=500)
        except Exception as e:
            #raise e
            return self.response.json({'error': str(e)}, status=500)

I did not find many tutorials or guides online where Masonite was used for RESTful APIs, so I have created a repo here for anyone who might find this useful.

As a feature request in 3.0: Perhaps a command such as craft auth:jwt that implements functionality like this could be added for those wanting to use Masonite for RESTful APIs.

josephmancuso commented 2 years ago

Closing in favor of #465. Will use this issue as reference