IndominusByte / fastapi-jwt-auth

FastAPI extension that provides JWT Auth support (secure, easy to use, and lightweight)
http://indominusbyte.github.io/fastapi-jwt-auth/
MIT License
630 stars 143 forks source link

Websocket Support #14

Closed SelfhostedPro closed 3 years ago

SelfhostedPro commented 3 years ago

Currently it looks as though websockets wont work with the standard require_jwt_auth() even when sent via cookies (which works with flask_jwt_extended). This is the error I'm getting:

ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 154, in run_asgi
    result = await self.app(self.scope, self.asgi_receive, self.asgi_send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/uvicorn/middleware/proxy_headers.py", line 45, in __call__
    return await self.app(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/fastapi/applications.py", line 179, in __call__
    await super().__call__(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/applications.py", line 111, in __call__
    await self.middleware_stack(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/middleware/errors.py", line 146, in __call__
    await self.app(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/exceptions.py", line 58, in __call__
    await self.app(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/routing.py", line 566, in __call__
    await route.handle(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/routing.py", line 283, in handle
    await self.app(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/routing.py", line 57, in app
    await func(session)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/fastapi/routing.py", line 228, in app
    await dependant.call(**values)
  File "./backend/api/routers/apps.py", line 171, in dashboard
    Authorize.jwt_required()
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/fastapi_jwt_auth/auth_jwt.py", line 670, in jwt_required
    self._verify_and_get_jwt_in_cookies('access',self._decode_issuer)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/fastapi_jwt_auth/auth_jwt.py", line 541, in _verify_and_get_jwt_in_cookies
    cookie = self._request.cookies.get(cookie_key)
AttributeError: 'NoneType' object has no attribute 'cookies'
SelfhostedPro commented 3 years ago

Possible solution would be to add a function that just validates the JWT and CSRF (if it's enabled) that can be imported so that I can use it in my websocket routes.

SelfhostedPro commented 3 years ago

Here's the function I'm currently trying to get this to work on:

@router.websocket("/stats")
async def dashboard(websocket: WebSocket):
    auth_success = await websocket_auth(websocket=websocket)
    if auth_success:
        await websocket.accept()
        tasks = []
        async with aiodocker.Docker() as docker:
            containers = []
            _containers = await docker.containers.list()
            for _app in _containers:
                if _app._container["State"] == "running":
                    containers.append(_app)
            for app in containers:
                _name = app._container["Names"][0][1:]
                container: DockerContainer = await docker.containers.get(_name)
                stats = container.stats(stream=True)
                tasks.append(process_container(_name, stats, websocket))
            await asyncio.gather(*tasks)
    else:
        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)

async def process_container(name, stats, websocket):
    cpu_total = 0.0
    cpu_system = 0.0
    cpu_percent = 0.0
    async for line in stats:
        if line["memory_stats"]:
            mem_current = line["memory_stats"]["usage"]
            mem_total = line["memory_stats"]["limit"]
            mem_percent = (mem_current / mem_total) * 100.0
        else:
            mem_current = None
            mem_total = None
            mem_percent = None

        try:
            cpu_percent, cpu_system, cpu_total = await calculate_cpu_percent2(
                line, cpu_total, cpu_system
            )
        except KeyError as e:
            print("error while getting new CPU stats: %r, falling back")
            cpu_percent = await calculate_cpu_percent(line)

        full_stats = {
            "name": name,
            "cpu_percent": cpu_percent,
            "mem_current": mem_current,
            "mem_total": mem_total,
            "mem_percent": mem_percent,
        }
        try:
            await websocket.send_text(json.dumps(full_stats))
        except Exception as e:
            pass

auth_success = await websocket_auth(websocket=websocket) was a function that used a function from FastAPI-users to validate the jwt token in the cookie.

async def websocket_auth(websocket: WebSocket):
    try:
        cookie = websocket._cookies["fastapiusersauth"]
        user = await cookie_authentication(cookie, user_db)
        if user and user.is_active:
            return user
        elif settings.DISABLE_AUTH == "True":
            return True
    except:
        if settings.DISABLE_AUTH == "True":
            return True
        else:
            return None

(Just wanted to add an example of what manual validation could look like)

IndominusByte commented 3 years ago

Hi @SelfhostedPro, I know the problem it is, fastapi-jwt-auth extract data which is cookies or headers from request instead from WebSocket, I will fix this in the next version thanks πŸ™

SelfhostedPro commented 3 years ago

Glad to hear it! Super excited to be using this in my project.

IndominusByte commented 3 years ago

Hi @SelfhostedPro you can get a new version for support WebSocket, also you can check the documentation here, if you have any question or suggestion please let me now 😁 , or if this version enough for you, you can close this issue thankyou πŸ™

SelfhostedPro commented 3 years ago

I'll try it out today and let you know how it goes. Thanks!

SelfhostedPro commented 3 years ago

I'm currently getting the following error in my websocket functions. Seems like it doesn't like me specifying the token argument for some reason.

TypeError: jwt_required() got an unexpected keyword argument 'token'

This is the function in question:

@router.websocket("/{app_name}/livelogs")
async def logs(websocket: WebSocket, app_name: str, Authorize: AuthJWT = Depends()):
    try:
        csrf = websocket._cookies["csrf_access_token"]
        Authorize.jwt_required("websocket", token=csrf)
    except AuthJWTException as err:
        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
    await websocket.accept()
    async with aiodocker.Docker() as docker:
        container: DockerContainer = await docker.containers.get(app_name)
        if container._container["State"]["Status"] == "running":
            stats = container.stats(stream=True)
            logs = container.log(stdout=True, stderr=True, follow=True)
            async for line in logs:
                try:
                    await websocket.send_text(line)
                except Exception as e:
                    return e
        else:
            await websocket.close(code=status.WS_1011_INTERNAL_ERROR)

Here's the full error:

ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 154, in run_asgi
    result = await self.app(self.scope, self.asgi_receive, self.asgi_send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/uvicorn/middleware/proxy_headers.py", line 45, in __call__
    return await self.app(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/fastapi/applications.py", line 179, in __call__
    await super().__call__(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/applications.py", line 111, in __call__
    await self.middleware_stack(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/middleware/errors.py", line 146, in __call__
    await self.app(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/exceptions.py", line 58, in __call__
    await self.app(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/routing.py", line 566, in __call__
    await route.handle(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/routing.py", line 283, in handle
    await self.app(scope, receive, send)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/starlette/routing.py", line 57, in app
    await func(session)
  File "/home/user/dev/work/venv/lib/python3.8/site-packages/fastapi/routing.py", line 228, in app
    await dependant.call(**values)
  File "./backend/api/routers/apps.py", line 118, in stats
    Authorize.jwt_required("websocket", token=csrf)
TypeError: jwt_required() got an unexpected keyword argument 'token'
IndominusByte commented 3 years ago

btw if you want to authorization with cookies you must be passing WebSocket instance to

    try:
        csrf = websocket._cookies["csrf_access_token"]
        Authorize.jwt_required("websocket", websocket=websocket, token=csrf) # like this
    except AuthJWTException: # delete as err if you no need that message
        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
SelfhostedPro commented 3 years ago

Changed over to that as well with no luck. Getting the following error: TypeError: jwt_required() got an unexpected keyword argument 'websocket'

@router.websocket("/{app_name}/livelogs")
async def logs(websocket: WebSocket, app_name: str, Authorize: AuthJWT = Depends()):
    try:
        csrf = websocket._cookies["csrf_access_token"]
        Authorize.jwt_required("websocket",websocket=websocket,csrf_token=csrf)
    except AuthJWTException:
        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
    await websocket.accept()
    async with aiodocker.Docker() as docker:
        container: DockerContainer = await docker.containers.get(app_name)
        if container._container["State"]["Status"] == "running":
            stats = container.stats(stream=True)
            logs = container.log(stdout=True, stderr=True, follow=True)
            async for line in logs:
                try:
                    await websocket.send_text(line)
                except Exception as e:
                    return e
        else:
            await websocket.close(code=status.WS_1011_INTERNAL_ERROR)

This is to show I'm on the latest version:

[user@user-desktop Yacht]$ pip show fastapi-jwt-auth
Name: fastapi-jwt-auth
Version: 0.5.0
Summary: FastAPI extension that provides JWT Auth support (secure, easy to use and lightweight)
Home-page: https://github.com/IndominusByte/fastapi-jwt-auth
Author: Nyoman Pradipta Dewantara
Author-email: nyomanpradipta120@gmail.com
License: UNKNOWN
Location: /home/user/dev/Yacht-work/Yacht/backend/venv/lib/python3.8/site-packages
Requires: fastapi, PyJWT
Required-by: 
SelfhostedPro commented 3 years ago

Here is the source code for that section of my app if you wanted to know that as well:

https://github.com/SelfhostedPro/Yacht/blob/ec26cab0bd4b2b119df6290566533f4a4cd57b72/backend/api/routers/apps.py#L90

IndominusByte commented 3 years ago

Can you try a fresh install? its doesn't make sense my package doesn't have websocket or token argument its like your application use my previous version which is doesn't have that all argument

SelfhostedPro commented 3 years ago

Deleted my project, cloned, setup a new venv, and installed with --no-cache-dir and still getting the same error. Is it possible pypi doesn't have the correct files in the new version?

SelfhostedPro commented 3 years ago

Checked on pypi and in my venv and it looks like it's the correct version.

SelfhostedPro commented 3 years ago

Also tried just doing positional arguments with no luck.

TypeError: jwt_required() takes 1 positional argument but 5 were given

@router.websocket("/stats")
async def dashboard(websocket: WebSocket, Authorize: AuthJWT = Depends()):
    try:
        csrf = websocket._cookies["csrf_access_token"]
        Authorize.jwt_required("websocket",None,websocket,csrf)
    except AuthJWTException:
        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
    await websocket.accept()
    tasks = []
    async with aiodocker.Docker() as docker:
        containers = []
        _containers = await docker.containers.list()
        for _app in _containers:
            if _app._container["State"] == "running":
                containers.append(_app)
        for app in containers:
            _name = app._container["Names"][0][1:]
            container: DockerContainer = await docker.containers.get(_name)
            stats = container.stats(stream=True)
            tasks.append(process_container(_name, stats, websocket))
        await asyncio.gather(*tasks)
SelfhostedPro commented 3 years ago

If you'd be willing to try out my project and see here are the instructions:

backend:

git clone https://github.com/selfhostedpro/yacht.git
cd Yacht
cd backend
pip install -r requirements.txt

frontend(in another terminal):

cd frontend
npm i
npm run serve

If you use vscode, this is the launch.json I use for debugging:

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: Module",
            "type": "python",
            "request": "launch",
            "module": "uvicorn",
            "args": [
                "backend.api.main:app",
                "--reload"
            ]
        }
    ]
} 
IndominusByte commented 3 years ago

Try this cat env/lib/python3.8/site-packages/fastapi_jwt_auth/auth_jwt.py then screenshot to me in the function jwt_required() ? Does it look like this?

Screen Shot 2020-11-07 at 00 37 19
SelfhostedPro commented 3 years ago

Yeah it does: image

IndominusByte commented 3 years ago

Okay, I will try to run your application

SelfhostedPro commented 3 years ago

Thank you. I appreciate it. One instruction I forgot is to make a config directory in the root folder of the project like this: image

Default username is admin@yacht.local default password is pass. You will also need to have docker installed and have one running container to get to the place where I'm having the error. (or you can use websocket king to simulate a user going to the following websocket: localhost:8080/api/apps/appname/livelogs )

SelfhostedPro commented 3 years ago

Have a friend who's using the same example as in your documentation and is getting the same error. Nothing is popping out to me as far as where the issue is in fastapi-jwt-auth though.

IndominusByte commented 3 years ago

Testing on your application and its working

Screen Shot 2020-11-07 at 02 23 54 Screen Shot 2020-11-07 at 02 24 18
SelfhostedPro commented 3 years ago

Are you using a virtual environment? I've never run into something like this before so I'm not really sure where to go from here as far as troubleshooting. I'll try re-imaging my machine after work today and see if that changes anything.

IndominusByte commented 3 years ago

I testing it on docker because you provide Dockerfile, and I don't get an error message as you get. and I try to different machine and work to

IndominusByte commented 3 years ago

Use the virtual environment in a different machine is working well to

SelfhostedPro commented 3 years ago

I can confirm it's working within docker. Thanks!

IndominusByte commented 3 years ago

Great! glad to hear that 😁 πŸ™

IndominusByte commented 3 years ago

I assume the original issue was resolved. But feel free to add more comments or create new issues πŸ˜„ πŸ™