diff --git a/docs/usage/current-user.md b/docs/usage/current-user.md index bbb6f234..c9e6244b 100644 --- a/docs/usage/current-user.md +++ b/docs/usage/current-user.md @@ -13,6 +13,7 @@ Return a dependency callable to retrieve currently authenticated user, passing t * `active`: If `True`, throw `401 Unauthorized` if the authenticated user is inactive. Defaults to `False`. * `verified`: If `True`, throw `403 Forbidden` if the authenticated user is not verified. Defaults to `False`. * `superuser`: If `True`, throw `403 Forbidden` if the authenticated user is not a superuser. Defaults to `False`. +* `get_enabled_backends`: Optional dependency callable returning a list of enabled authentication backends. Useful if you want to dynamically enable some authentication backends based on external logic, like a configuration in database. By default, all specified authentication backends are enabled. *Please not however that every backends will appear in the OpenAPI documentation, as FastAPI resolves it statically.* !!! tip "Create it once and reuse it" This function is a **factory**, a function returning another function 🤯 @@ -63,6 +64,41 @@ def protected_route(user: User = Depends(current_superuser)): return f"Hello, {user.email}" ``` +### Dynamically enable authentication backends + +!!! warning + This is an advanced feature for cases where you have several authentication backends that are enabled conditionally. In most cases, you won't need this option. + +```py +from fastapi import Request +from fastapi_users.authentication import CookieAuthentication, JWTAuthentication + +SECRET = "SECRET" +jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600) +cookie_authentication = CookieAuthentication(secret=SECRET, lifetime_seconds=3600) + + +async def get_enabled_backends(request: Request): + """Return the enabled dependencies following custom logic.""" + if request.url.path == "/protected-route-only-jwt": + return [jwt_authentication] + else: + return [cookie_authentication, jwt_authentication] + + +current_active_user = fastapi_users.current_user(active=True, get_enabled_backends=get_enabled_backends) + + +@app.get("/protected-route") +def protected_route(user: User = Depends(current_active_user)): + return f"Hello, {user.email}. You are authenticated with a cookie or a JWT." + + +@app.get("/protected-route-only-jwt") +def protected_route(user: User = Depends(current_active_user)): + return f"Hello, {user.email}. You are authenticated with a JWT." +``` + ## In a path operation If you don't need the user in the route logic, you can use this syntax: diff --git a/fastapi_users/authentication/__init__.py b/fastapi_users/authentication/__init__.py index 25a5a23f..0e5298a9 100644 --- a/fastapi_users/authentication/__init__.py +++ b/fastapi_users/authentication/__init__.py @@ -1,6 +1,6 @@ import re from inspect import Parameter, Signature -from typing import Optional, Sequence +from typing import Callable, Optional, Sequence from fastapi import Depends, HTTPException, status from makefun import with_signature @@ -26,6 +26,9 @@ class DuplicateBackendNamesError(Exception): pass +EnabledBackendsDependency = Callable[..., Sequence[BaseAuthentication]] + + class Authenticator: """ Provides dependency callables to retrieve authenticated user. @@ -54,6 +57,7 @@ class Authenticator: active: bool = False, verified: bool = False, superuser: bool = False, + get_enabled_backends: Optional[EnabledBackendsDependency] = None, ): """ Return a dependency callable to retrieve currently authenticated user. @@ -68,6 +72,13 @@ class Authenticator: the authenticated user is not verified. Defaults to `False`. :param superuser: If `True`, throw `403 Forbidden` if the authenticated user is not a superuser. Defaults to `False`. + :param get_enabled_backends: Optional dependency callable returning + a list of enabled authentication backends. + Useful if you want to dynamically enable some authentication backends + based on external logic, like a configuration in database. + By default, all specified authentication backends are enabled. + Please not however that every backends will appear in the OpenAPI documentation, + as FastAPI resolves it statically. """ # Here comes some blood magic 🧙‍♂️ # Thank to "makefun", we are able to generate callable @@ -88,6 +99,14 @@ class Authenticator: default=Depends(self.get_user_manager), ) ] + if get_enabled_backends is not None: + parameters += [ + Parameter( + name="enabled_backends", + kind=Parameter.POSITIONAL_OR_KEYWORD, + default=Depends(get_enabled_backends), + ) + ] signature = Signature(parameters) except ValueError: raise DuplicateBackendNamesError() @@ -116,12 +135,16 @@ class Authenticator: **kwargs ) -> Optional[models.UD]: user: Optional[models.UD] = None + enabled_backends: Sequence[BaseAuthentication] = kwargs.get( + "enabled_backends", self.backends + ) for backend in self.backends: - token: str = kwargs[name_to_variable_name(backend.name)] - if token: - user = await backend(token, user_manager) - if user: - break + if backend in enabled_backends: + token: str = kwargs[name_to_variable_name(backend.name)] + if token: + user = await backend(token, user_manager) + if user: + break status_code = status.HTTP_401_UNAUTHORIZED if user: diff --git a/tests/conftest.py b/tests/conftest.py index 2accf651..c5edb47d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,18 @@ import asyncio -from typing import Any, AsyncGenerator, Callable, Generic, List, Optional, Type, Union +from typing import Any, AsyncGenerator, Callable, Generic, Optional, Type, Union from unittest.mock import MagicMock import httpx import pytest from asgi_lifespan import LifespanManager -from fastapi import Depends, FastAPI, Response +from fastapi import FastAPI, Response from fastapi.security import OAuth2PasswordBearer from httpx_oauth.oauth2 import OAuth2 from pydantic import UUID4, SecretStr from pytest_mock import MockerFixture from fastapi_users import models -from fastapi_users.authentication import Authenticator, BaseAuthentication +from fastapi_users.authentication import BaseAuthentication from fastapi_users.db import BaseUserDatabase from fastapi_users.jwt import SecretType from fastapi_users.manager import ( @@ -476,39 +476,6 @@ def get_test_client(): return _get_test_client -@pytest.fixture -@pytest.mark.asyncio -def get_test_auth_client(get_user_manager, get_test_client): - async def _get_test_auth_client( - backends: List[BaseAuthentication], - ) -> AsyncGenerator[httpx.AsyncClient, None]: - app = FastAPI() - authenticator = Authenticator(backends, get_user_manager) - - @app.get("/test-current-user") - def test_current_user(user: UserDB = Depends(authenticator.current_user())): - return user - - @app.get("/test-current-active-user") - def test_current_active_user( - user: UserDB = Depends(authenticator.current_user(active=True)), - ): - return user - - @app.get("/test-current-superuser") - def test_current_superuser( - user: UserDB = Depends( - authenticator.current_user(active=True, superuser=True) - ), - ): - return user - - async for client in get_test_client(app): - yield client - - return _get_test_auth_client - - @pytest.fixture def oauth_client() -> OAuth2: CLIENT_ID = "CLIENT_ID" diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 93291cbf..30e62f32 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,12 +1,18 @@ -from typing import Generic, Optional +from typing import AsyncGenerator, Callable, Generic, List, Optional, Sequence +import httpx import pytest -from fastapi import Request, status +from fastapi import Depends, FastAPI, Request, status from fastapi.security.base import SecurityBase from fastapi_users import models -from fastapi_users.authentication import BaseAuthentication, DuplicateBackendNamesError +from fastapi_users.authentication import ( + Authenticator, + BaseAuthentication, + DuplicateBackendNamesError, +) from fastapi_users.manager import BaseUserManager +from tests.conftest import UserDB class MockSecurityScheme(SecurityBase): @@ -45,6 +51,54 @@ class BackendUser( return self.user +@pytest.fixture +@pytest.mark.asyncio +def get_test_auth_client(get_user_manager, get_test_client): + async def _get_test_auth_client( + backends: List[BaseAuthentication], + get_enabled_backends: Optional[ + Callable[..., Sequence[BaseAuthentication]] + ] = None, + ) -> AsyncGenerator[httpx.AsyncClient, None]: + app = FastAPI() + authenticator = Authenticator(backends, get_user_manager) + + @app.get("/test-current-user") + def test_current_user( + user: UserDB = Depends( + authenticator.current_user(get_enabled_backends=get_enabled_backends) + ), + ): + return user + + @app.get("/test-current-active-user") + def test_current_active_user( + user: UserDB = Depends( + authenticator.current_user( + active=True, get_enabled_backends=get_enabled_backends + ) + ), + ): + return user + + @app.get("/test-current-superuser") + def test_current_superuser( + user: UserDB = Depends( + authenticator.current_user( + active=True, + superuser=True, + get_enabled_backends=get_enabled_backends, + ) + ), + ): + return user + + async for client in get_test_client(app): + yield client + + return _get_test_auth_client + + @pytest.mark.authentication @pytest.mark.asyncio async def test_authenticator(get_test_auth_client, user): @@ -63,6 +117,22 @@ async def test_authenticator_none(get_test_auth_client): assert response.status_code == status.HTTP_401_UNAUTHORIZED +@pytest.mark.authentication +@pytest.mark.asyncio +async def test_authenticator_none_enabled(get_test_auth_client, user): + backend_none = BackendNone() + backend_user = BackendUser(user) + + async def get_enabled_backends(): + return [backend_none] + + async for client in get_test_auth_client( + [backend_none, backend_user], get_enabled_backends + ): + response = await client.get("/test-current-user") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.authentication @pytest.mark.asyncio async def test_authenticators_with_same_name(get_test_auth_client):