mirror of
https://github.com/fastapi-users/fastapi-users.git
synced 2026-03-13 07:49:55 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user