Revamp authentication routes structure (#201)

* Fix #68: use makefun to generate dynamic dependencies

* Remove every Starlette imports

* Split every routers and remove event handlers

* Make users router optional

* Pass after_update handler to get_users_router

* Update documentation

* Remove test file

* Write migration doc for splitted routers
This commit is contained in:
François Voron
2020-05-24 10:18:01 +02:00
committed by GitHub
parent 0a0dcadfdc
commit 7721f8dcc1
48 changed files with 1633 additions and 1167 deletions

View File

@@ -1,8 +1,9 @@
import re
from inspect import Parameter, Signature
from typing import Sequence
from fastapi import HTTPException
from starlette import status
from starlette.requests import Request
from fastapi import Depends, HTTPException, status
from makefun import with_signature
from fastapi_users.authentication.base import BaseAuthentication # noqa: F401
from fastapi_users.authentication.cookie import CookieAuthentication # noqa: F401
@@ -10,6 +11,20 @@ from fastapi_users.authentication.jwt import JWTAuthentication # noqa: F401
from fastapi_users.db import BaseUserDatabase
from fastapi_users.models import BaseUserDB
INVALID_CHARS_PATTERN = re.compile(r"[^0-9a-zA-Z_]")
INVALID_LEADING_CHARS_PATTERN = re.compile(r"^[^a-zA-Z_]+")
def name_to_variable_name(name: str) -> str:
"""Transform a backend name string into a string safe to use as variable name."""
name = re.sub(INVALID_CHARS_PATTERN, "", name)
name = re.sub(INVALID_LEADING_CHARS_PATTERN, "", name)
return name
class DuplicateBackendNamesError(Exception):
pass
class Authenticator:
"""
@@ -32,26 +47,52 @@ class Authenticator:
self.backends = backends
self.user_db = user_db
async def get_current_user(self, request: Request) -> BaseUserDB:
return await self._authenticate(request)
# Here comes some blood magic 🧙‍♂️
# Thank to "makefun", we are able to generate callable
# with a dynamic number of dependencies at runtime.
# This way, each security schemes are detected by the OpenAPI generator.
try:
parameters = [
Parameter(
name=name_to_variable_name(backend.name),
kind=Parameter.POSITIONAL_OR_KEYWORD,
default=Depends(backend.scheme), # type: ignore
)
for backend in self.backends
]
signature = Signature(parameters)
except ValueError:
raise DuplicateBackendNamesError()
async def get_current_active_user(self, request: Request) -> BaseUserDB:
user = await self.get_current_user(request)
if not user.is_active:
raise self._get_credentials_exception()
return user
@with_signature(signature, func_name="get_current_user")
async def get_current_user(*args, **kwargs):
return await self._authenticate(*args, **kwargs)
async def get_current_superuser(self, request: Request) -> BaseUserDB:
user = await self.get_current_active_user(request)
if not user.is_superuser:
raise self._get_credentials_exception(status.HTTP_403_FORBIDDEN)
return user
@with_signature(signature, func_name="get_current_active_user")
async def get_current_active_user(*args, **kwargs):
user = await get_current_user(*args, **kwargs)
if not user.is_active:
raise self._get_credentials_exception()
return user
async def _authenticate(self, request: Request) -> BaseUserDB:
@with_signature(signature, func_name="get_current_superuser")
async def get_current_superuser(*args, **kwargs):
user = await get_current_active_user(*args, **kwargs)
if not user.is_superuser:
raise self._get_credentials_exception(status.HTTP_403_FORBIDDEN)
return user
self.get_current_user = get_current_user
self.get_current_active_user = get_current_active_user
self.get_current_superuser = get_current_superuser
async def _authenticate(self, *args, **kwargs) -> BaseUserDB:
for backend in self.backends:
user = await backend(request, self.user_db)
if user is not None:
return user
token: str = kwargs[name_to_variable_name(backend.name)]
if token:
user = await backend(token, self.user_db)
if user is not None:
return user
raise self._get_credentials_exception()
def _get_credentials_exception(

View File

@@ -1,28 +1,34 @@
from typing import Any, Optional
from typing import Any, Generic, Optional, TypeVar
from starlette.requests import Request
from starlette.responses import Response
from fastapi import Response
from fastapi.security.base import SecurityBase
from fastapi_users.db import BaseUserDatabase
from fastapi_users.models import BaseUserDB
T = TypeVar("T")
class BaseAuthentication:
class BaseAuthentication(Generic[T]):
"""
Base authentication backend.
Every backend should derive from this class.
:param name: Name of the backend. It will be used to name the login route.
:param name: Name of the backend.
:param logout: Whether or not this backend has a logout process.
"""
scheme: SecurityBase
name: str
logout: bool
def __init__(self, name: str = "base"):
def __init__(self, name: str = "base", logout: bool = False):
self.name = name
self.logout = logout
async def __call__(
self, request: Request, user_db: BaseUserDatabase
self, credentials: Optional[T], user_db: BaseUserDatabase
) -> Optional[BaseUserDB]:
raise NotImplementedError()

View File

@@ -1,14 +1,17 @@
from typing import Any, Optional
import jwt
from fastapi import Response
from fastapi.security import APIKeyCookie
from starlette.requests import Request
from starlette.responses import Response
from pydantic import UUID4
from fastapi_users.authentication.jwt import JWTAuthentication
from fastapi_users.authentication import BaseAuthentication
from fastapi_users.db.base import BaseUserDatabase
from fastapi_users.models import BaseUserDB
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
class CookieAuthentication(JWTAuthentication):
class CookieAuthentication(BaseAuthentication[str]):
"""
Authentication backend using a cookie.
@@ -24,6 +27,9 @@ class CookieAuthentication(JWTAuthentication):
:param name: Name of the backend. It will be used to name the login route.
"""
scheme: APIKeyCookie
token_audience: str = "fastapi-users:auth"
secret: str
lifetime_seconds: int
cookie_name: str
cookie_path: str
@@ -42,14 +48,40 @@ class CookieAuthentication(JWTAuthentication):
cookie_httponly: bool = True,
name: str = "cookie",
):
super().__init__(secret, lifetime_seconds, name=name)
super().__init__(name, logout=True)
self.secret = secret
self.lifetime_seconds = lifetime_seconds
self.cookie_name = cookie_name
self.cookie_path = cookie_path
self.cookie_domain = cookie_domain
self.cookie_secure = cookie_secure
self.cookie_httponly = cookie_httponly
self.api_key_cookie = APIKeyCookie(name=self.cookie_name, auto_error=False)
self.scheme = APIKeyCookie(name=self.cookie_name, auto_error=False)
async def __call__(
self, credentials: Optional[str], user_db: BaseUserDatabase,
) -> Optional[BaseUserDB]:
if credentials is None:
return None
try:
data = jwt.decode(
credentials,
self.secret,
audience=self.token_audience,
algorithms=[JWT_ALGORITHM],
)
user_id = data.get("user_id")
if user_id is None:
return None
except jwt.PyJWTError:
return None
try:
user_uiid = UUID4(user_id)
return await user_db.get(user_uiid)
except ValueError:
return None
async def get_login_response(self, user: BaseUserDB, response: Response) -> Any:
token = await self._generate_token(user)
@@ -72,5 +104,6 @@ class CookieAuthentication(JWTAuthentication):
self.cookie_name, path=self.cookie_path, domain=self.cookie_domain
)
async def _retrieve_token(self, request: Request) -> Optional[str]:
return await self.api_key_cookie.__call__(request)
async def _generate_token(self, user: BaseUserDB) -> str:
data = {"user_id": str(user.id), "aud": self.token_audience}
return generate_jwt(data, self.lifetime_seconds, self.secret, JWT_ALGORITHM)

View File

@@ -1,10 +1,9 @@
from typing import Any, Optional
import jwt
from fastapi import Response
from fastapi.security import OAuth2PasswordBearer
from pydantic import UUID4
from starlette.requests import Request
from starlette.responses import Response
from fastapi_users.authentication.base import BaseAuthentication
from fastapi_users.db.base import BaseUserDatabase
@@ -12,7 +11,7 @@ from fastapi_users.models import BaseUserDB
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
class JWTAuthentication(BaseAuthentication):
class JWTAuthentication(BaseAuthentication[str]):
"""
Authentication backend using a JWT in a Bearer header.
@@ -22,6 +21,7 @@ class JWTAuthentication(BaseAuthentication):
:param name: Name of the backend. It will be used to name the login route.
"""
scheme: OAuth2PasswordBearer
token_audience: str = "fastapi-users:auth"
secret: str
lifetime_seconds: int
@@ -33,21 +33,20 @@ class JWTAuthentication(BaseAuthentication):
tokenUrl: str = "/users/login",
name: str = "jwt",
):
super().__init__(name)
super().__init__(name, logout=False)
self.scheme = OAuth2PasswordBearer(tokenUrl, auto_error=False)
self.secret = secret
self.lifetime_seconds = lifetime_seconds
self.scheme = OAuth2PasswordBearer(tokenUrl, auto_error=False)
async def __call__(
self, request: Request, user_db: BaseUserDatabase,
self, credentials: Optional[str], user_db: BaseUserDatabase,
) -> Optional[BaseUserDB]:
token = await self._retrieve_token(request)
if token is None:
if credentials is None:
return None
try:
data = jwt.decode(
token,
credentials,
self.secret,
audience=self.token_audience,
algorithms=[JWT_ALGORITHM],
@@ -68,9 +67,6 @@ class JWTAuthentication(BaseAuthentication):
token = await self._generate_token(user)
return {"token": token}
async def _retrieve_token(self, request: Request) -> Optional[str]:
return await self.scheme.__call__(request)
async def _generate_token(self, user: BaseUserDB) -> str:
data = {"user_id": str(user.id), "aud": self.token_audience}
return generate_jwt(data, self.lifetime_seconds, self.secret, JWT_ALGORITHM)