Add get_enabled_backends advanced feature

This commit is contained in:
François Voron
2021-09-21 08:18:20 +02:00
parent a82dfc262d
commit e819bb0b65
4 changed files with 141 additions and 45 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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"

View File

@@ -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):