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)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
from collections import defaultdict
|
||||
from typing import Callable, DefaultDict, List, Sequence, Type
|
||||
from typing import Any, Callable, Dict, Optional, Sequence, Type
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from httpx_oauth.oauth2 import BaseOAuth2
|
||||
|
||||
from fastapi_users import models
|
||||
from fastapi_users.authentication import Authenticator, BaseAuthentication
|
||||
from fastapi_users.db import BaseUserDatabase
|
||||
from fastapi_users.router import (
|
||||
Event,
|
||||
EventHandlersRouter,
|
||||
get_auth_router,
|
||||
get_oauth_router,
|
||||
get_user_router,
|
||||
get_register_router,
|
||||
get_reset_password_router,
|
||||
get_users_router,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,20 +25,18 @@ class FastAPIUsers:
|
||||
:param user_create_model: Pydantic model for creating a user.
|
||||
:param user_update_model: Pydantic model for updating a user.
|
||||
:param user_db_model: Pydantic model of a DB representation of a user.
|
||||
:param reset_password_token_secret: Secret to encode reset password token.
|
||||
:param reset_password_token_lifetime_seconds: Lifetime of reset password token.
|
||||
|
||||
:attribute router: Router exposing authentication routes.
|
||||
:attribute oauth_routers: List of OAuth routers created through `get_oauth_router`.
|
||||
:attribute get_current_user: Dependency callable to inject authenticated user.
|
||||
:attribute get_current_active_user: Dependency callable to inject active user.
|
||||
:attribute get_current_superuser: Dependency callable to inject superuser.
|
||||
"""
|
||||
|
||||
db: BaseUserDatabase
|
||||
authenticator: Authenticator
|
||||
router: EventHandlersRouter
|
||||
oauth_routers: List[EventHandlersRouter]
|
||||
_user_model: Type[models.BaseUser]
|
||||
_user_create_model: Type[models.BaseUserCreate]
|
||||
_user_update_model: Type[models.BaseUserUpdate]
|
||||
_user_db_model: Type[models.BaseUserDB]
|
||||
_event_handlers: DefaultDict[Event, List[Callable]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -47,44 +46,78 @@ class FastAPIUsers:
|
||||
user_create_model: Type[models.BaseUserCreate],
|
||||
user_update_model: Type[models.BaseUserUpdate],
|
||||
user_db_model: Type[models.BaseUserDB],
|
||||
reset_password_token_secret: str,
|
||||
reset_password_token_lifetime_seconds: int = 3600,
|
||||
):
|
||||
self.db = db
|
||||
self.authenticator = Authenticator(auth_backends, db)
|
||||
self.router = get_user_router(
|
||||
self.db,
|
||||
user_model,
|
||||
user_create_model,
|
||||
user_update_model,
|
||||
user_db_model,
|
||||
self.authenticator,
|
||||
reset_password_token_secret,
|
||||
reset_password_token_lifetime_seconds,
|
||||
self.router = get_users_router(
|
||||
self.db, user_model, user_update_model, user_db_model, self.authenticator,
|
||||
)
|
||||
self.oauth_routers = []
|
||||
|
||||
self._user_model = user_model
|
||||
self._user_db_model = user_db_model
|
||||
self._user_create_model = user_create_model
|
||||
self._user_update_model = user_update_model
|
||||
self._user_db_model = user_db_model
|
||||
self._event_handlers = defaultdict(list)
|
||||
|
||||
self.get_current_user = self.authenticator.get_current_user
|
||||
self.get_current_active_user = self.authenticator.get_current_active_user
|
||||
self.get_current_superuser = self.authenticator.get_current_superuser
|
||||
|
||||
def on_after_register(self) -> Callable:
|
||||
"""Add an event handler on successful registration."""
|
||||
return self._on_event(Event.ON_AFTER_REGISTER)
|
||||
def get_register_router(
|
||||
self, after_register: Optional[Callable[[models.UD, Request], None]] = None,
|
||||
) -> APIRouter:
|
||||
"""
|
||||
Return a router with a register route.
|
||||
|
||||
def on_after_forgot_password(self) -> Callable:
|
||||
"""Add an event handler on successful forgot password request."""
|
||||
return self._on_event(Event.ON_AFTER_FORGOT_PASSWORD)
|
||||
:param after_register: Optional function called
|
||||
after a successful registration.
|
||||
"""
|
||||
return get_register_router(
|
||||
self.db,
|
||||
self._user_model,
|
||||
self._user_create_model,
|
||||
self._user_db_model,
|
||||
after_register,
|
||||
)
|
||||
|
||||
def on_after_update(self) -> Callable:
|
||||
"""Add an event handler on successful update user request."""
|
||||
return self._on_event(Event.ON_AFTER_UPDATE)
|
||||
def get_reset_password_router(
|
||||
self,
|
||||
reset_password_token_secret: str,
|
||||
reset_password_token_lifetime_seconds: int = 3600,
|
||||
after_forgot_password: Optional[
|
||||
Callable[[models.UD, str, Request], None]
|
||||
] = None,
|
||||
) -> APIRouter:
|
||||
"""
|
||||
Return a reset password process router.
|
||||
|
||||
:param reset_password_token_secret: Secret to encode reset password token.
|
||||
:param reset_password_token_lifetime_seconds: Lifetime of reset password token.
|
||||
:param after_forgot_password: Optional function called after a successful
|
||||
forgot password request.
|
||||
"""
|
||||
return get_reset_password_router(
|
||||
self.db,
|
||||
reset_password_token_secret,
|
||||
reset_password_token_lifetime_seconds,
|
||||
after_forgot_password,
|
||||
)
|
||||
|
||||
def get_auth_router(self, backend: BaseAuthentication) -> APIRouter:
|
||||
"""
|
||||
Return an auth router for a given authentication backend.
|
||||
|
||||
:param backend: The authentication backend instance.
|
||||
"""
|
||||
return get_auth_router(backend, self.db, self.authenticator)
|
||||
|
||||
def get_oauth_router(
|
||||
self, oauth_client: BaseOAuth2, state_secret: str, redirect_url: str = None
|
||||
) -> EventHandlersRouter:
|
||||
self,
|
||||
oauth_client: BaseOAuth2,
|
||||
state_secret: str,
|
||||
redirect_url: str = None,
|
||||
after_register: Optional[Callable[[models.UD, Request], None]] = None,
|
||||
) -> APIRouter:
|
||||
"""
|
||||
Return an OAuth router for a given OAuth client.
|
||||
|
||||
@@ -92,30 +125,36 @@ class FastAPIUsers:
|
||||
:param state_secret: Secret used to encode the state JWT.
|
||||
:param redirect_url: Optional arbitrary redirect URL for the OAuth2 flow.
|
||||
If not given, the URL to the callback endpoint will be generated.
|
||||
:param after_register: Optional function called
|
||||
after a successful registration.
|
||||
"""
|
||||
oauth_router = get_oauth_router(
|
||||
return get_oauth_router(
|
||||
oauth_client,
|
||||
self.db,
|
||||
self._user_db_model,
|
||||
self.authenticator,
|
||||
state_secret,
|
||||
redirect_url,
|
||||
after_register,
|
||||
)
|
||||
|
||||
for event_type in self._event_handlers:
|
||||
for handler in self._event_handlers[event_type]:
|
||||
oauth_router.add_event_handler(event_type, handler)
|
||||
def get_users_router(
|
||||
self,
|
||||
after_update: Optional[
|
||||
Callable[[models.UD, Dict[str, Any], Request], None]
|
||||
] = None,
|
||||
) -> APIRouter:
|
||||
"""
|
||||
Return a router with routes to manage users.
|
||||
|
||||
self.oauth_routers.append(oauth_router)
|
||||
|
||||
return oauth_router
|
||||
|
||||
def _on_event(self, event_type: Event) -> Callable:
|
||||
def decorator(func: Callable) -> Callable:
|
||||
self._event_handlers[event_type].append(func)
|
||||
self.router.add_event_handler(event_type, func)
|
||||
for oauth_router in self.oauth_routers:
|
||||
oauth_router.add_event_handler(event_type, func)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
:param after_update: Optional function called
|
||||
after a successful user update.
|
||||
"""
|
||||
return get_users_router(
|
||||
self.db,
|
||||
self._user_model,
|
||||
self._user_update_model,
|
||||
self._user_db_model,
|
||||
self.authenticator,
|
||||
after_update,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from fastapi_users.router.common import ( # noqa: F401
|
||||
ErrorCode,
|
||||
Event,
|
||||
EventHandlersRouter,
|
||||
)
|
||||
from fastapi_users.router.auth import get_auth_router # noqa: F401
|
||||
from fastapi_users.router.common import ErrorCode # noqa: F401
|
||||
from fastapi_users.router.oauth import get_oauth_router # noqa: F401
|
||||
from fastapi_users.router.users import get_user_router # noqa: F401
|
||||
from fastapi_users.router.register import get_register_router # noqa: F401
|
||||
from fastapi_users.router.reset import get_reset_password_router # noqa: F401
|
||||
from fastapi_users.router.users import get_users_router # noqa: F401
|
||||
|
||||
40
fastapi_users/router/auth.py
Normal file
40
fastapi_users/router/auth.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from fastapi_users import models
|
||||
from fastapi_users.authentication import Authenticator, BaseAuthentication
|
||||
from fastapi_users.db import BaseUserDatabase
|
||||
from fastapi_users.router.common import ErrorCode
|
||||
|
||||
|
||||
def get_auth_router(
|
||||
backend: BaseAuthentication,
|
||||
user_db: BaseUserDatabase[models.BaseUserDB],
|
||||
authenticator: Authenticator,
|
||||
) -> APIRouter:
|
||||
"""Generate a router with login/logout routes for an authentication backend."""
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
response: Response, credentials: OAuth2PasswordRequestForm = Depends()
|
||||
):
|
||||
user = await user_db.authenticate(credentials)
|
||||
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.LOGIN_BAD_CREDENTIALS,
|
||||
)
|
||||
|
||||
return await backend.get_login_response(user, response)
|
||||
|
||||
if backend.logout:
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response, user=Depends(authenticator.get_current_active_user)
|
||||
):
|
||||
return await backend.get_logout_response(user, response)
|
||||
|
||||
return router
|
||||
@@ -1,9 +1,5 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from enum import Enum, auto
|
||||
from typing import Callable, DefaultDict, List
|
||||
|
||||
from fastapi import APIRouter
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class ErrorCode:
|
||||
@@ -12,25 +8,8 @@ class ErrorCode:
|
||||
RESET_PASSWORD_BAD_TOKEN = "RESET_PASSWORD_BAD_TOKEN"
|
||||
|
||||
|
||||
class Event(Enum):
|
||||
ON_AFTER_REGISTER = auto()
|
||||
ON_AFTER_FORGOT_PASSWORD = auto()
|
||||
ON_AFTER_UPDATE = auto()
|
||||
|
||||
|
||||
class EventHandlersRouter(APIRouter):
|
||||
event_handlers: DefaultDict[Event, List[Callable]]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.event_handlers = defaultdict(list)
|
||||
|
||||
def add_event_handler(self, event_type: Event, func: Callable) -> None:
|
||||
self.event_handlers[event_type].append(func)
|
||||
|
||||
async def run_handlers(self, event_type: Event, *args, **kwargs) -> None:
|
||||
for handler in self.event_handlers[event_type]:
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler(*args, **kwargs)
|
||||
else:
|
||||
handler(*args, **kwargs)
|
||||
async def run_handler(handler: Callable, *args, **kwargs):
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler(*args, **kwargs)
|
||||
else:
|
||||
handler(*args, **kwargs)
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
from typing import Dict, List, Type, cast
|
||||
from typing import Callable, Dict, List, Optional, Type, cast
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback
|
||||
from httpx_oauth.oauth2 import BaseOAuth2
|
||||
from starlette import status
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from fastapi_users import models
|
||||
from fastapi_users.authentication import Authenticator
|
||||
from fastapi_users.db import BaseUserDatabase
|
||||
from fastapi_users.password import generate_password, get_password_hash
|
||||
from fastapi_users.router.common import ErrorCode, Event, EventHandlersRouter
|
||||
from fastapi_users.router.common import ErrorCode, run_handler
|
||||
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
|
||||
|
||||
STATE_TOKEN_AUDIENCE = "fastapi-users:oauth-state"
|
||||
@@ -38,9 +35,10 @@ def get_oauth_router(
|
||||
authenticator: Authenticator,
|
||||
state_secret: str,
|
||||
redirect_url: str = None,
|
||||
) -> EventHandlersRouter:
|
||||
after_register: Optional[Callable[[models.UD, Request], None]] = None,
|
||||
) -> APIRouter:
|
||||
"""Generate a router with the OAuth routes."""
|
||||
router = EventHandlersRouter()
|
||||
router = APIRouter()
|
||||
callback_route_name = f"{oauth_client.name}-callback"
|
||||
|
||||
if redirect_url is not None:
|
||||
@@ -122,7 +120,8 @@ def get_oauth_router(
|
||||
oauth_accounts=[new_oauth_account],
|
||||
)
|
||||
await user_db.create(user)
|
||||
await router.run_handlers(Event.ON_AFTER_REGISTER, user, request)
|
||||
if after_register:
|
||||
await run_handler(after_register, user, request)
|
||||
else:
|
||||
# Update oauth
|
||||
updated_oauth_accounts = []
|
||||
|
||||
45
fastapi_users/router/register.py
Normal file
45
fastapi_users/router/register.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from typing import Callable, Optional, Type, cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from fastapi_users import models
|
||||
from fastapi_users.db import BaseUserDatabase
|
||||
from fastapi_users.password import get_password_hash
|
||||
from fastapi_users.router.common import ErrorCode, run_handler
|
||||
|
||||
|
||||
def get_register_router(
|
||||
user_db: BaseUserDatabase[models.BaseUserDB],
|
||||
user_model: Type[models.BaseUser],
|
||||
user_create_model: Type[models.BaseUserCreate],
|
||||
user_db_model: Type[models.BaseUserDB],
|
||||
after_register: Optional[Callable[[models.UD, Request], None]] = None,
|
||||
) -> APIRouter:
|
||||
"""Generate a router with the register route."""
|
||||
router = APIRouter()
|
||||
|
||||
@router.post(
|
||||
"/register", response_model=user_model, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def register(request: Request, user: user_create_model): # type: ignore
|
||||
user = cast(models.BaseUserCreate, user) # Prevent mypy complain
|
||||
existing_user = await user_db.get_by_email(user.email)
|
||||
|
||||
if existing_user is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.REGISTER_USER_ALREADY_EXISTS,
|
||||
)
|
||||
|
||||
hashed_password = get_password_hash(user.password)
|
||||
db_user = user_db_model(
|
||||
**user.create_update_dict(), hashed_password=hashed_password
|
||||
)
|
||||
created_user = await user_db.create(db_user)
|
||||
|
||||
if after_register:
|
||||
await run_handler(after_register, created_user, request)
|
||||
|
||||
return created_user
|
||||
|
||||
return router
|
||||
82
fastapi_users/router/reset.py
Normal file
82
fastapi_users/router/reset.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from typing import Callable, Optional
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Body, HTTPException, Request, status
|
||||
from pydantic import UUID4, EmailStr
|
||||
|
||||
from fastapi_users import models
|
||||
from fastapi_users.db import BaseUserDatabase
|
||||
from fastapi_users.password import get_password_hash
|
||||
from fastapi_users.router.common import ErrorCode, run_handler
|
||||
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
|
||||
|
||||
RESET_PASSWORD_TOKEN_AUDIENCE = "fastapi-users:reset"
|
||||
|
||||
|
||||
def get_reset_password_router(
|
||||
user_db: BaseUserDatabase[models.BaseUserDB],
|
||||
reset_password_token_secret: str,
|
||||
reset_password_token_lifetime_seconds: int = 3600,
|
||||
after_forgot_password: Optional[Callable[[models.UD, str, Request], None]] = None,
|
||||
) -> APIRouter:
|
||||
"""Generate a router with the reset password routes."""
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/forgot-password", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def forgot_password(
|
||||
request: Request, email: EmailStr = Body(..., embed=True)
|
||||
):
|
||||
user = await user_db.get_by_email(email)
|
||||
|
||||
if user is not None and user.is_active:
|
||||
token_data = {"user_id": str(user.id), "aud": RESET_PASSWORD_TOKEN_AUDIENCE}
|
||||
token = generate_jwt(
|
||||
token_data,
|
||||
reset_password_token_lifetime_seconds,
|
||||
reset_password_token_secret,
|
||||
)
|
||||
if after_forgot_password:
|
||||
await run_handler(after_forgot_password, user, token, request)
|
||||
|
||||
return None
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(token: str = Body(...), password: str = Body(...)):
|
||||
try:
|
||||
data = jwt.decode(
|
||||
token,
|
||||
reset_password_token_secret,
|
||||
audience=RESET_PASSWORD_TOKEN_AUDIENCE,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
)
|
||||
user_id = data.get("user_id")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
|
||||
)
|
||||
|
||||
try:
|
||||
user_uiid = UUID4(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
|
||||
)
|
||||
|
||||
user = await user_db.get(user_uiid)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
|
||||
)
|
||||
|
||||
user.hashed_password = get_password_hash(password)
|
||||
await user_db.update(user)
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -1,70 +1,25 @@
|
||||
from typing import Any, Dict, Type, cast
|
||||
from typing import Any, Callable, Dict, Optional, Type, cast
|
||||
|
||||
import jwt
|
||||
from fastapi import Body, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import UUID4, EmailStr
|
||||
from starlette import status
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import UUID4
|
||||
|
||||
from fastapi_users import models
|
||||
from fastapi_users.authentication import Authenticator, BaseAuthentication
|
||||
from fastapi_users.authentication import Authenticator
|
||||
from fastapi_users.db import BaseUserDatabase
|
||||
from fastapi_users.password import get_password_hash
|
||||
from fastapi_users.router.common import ErrorCode, Event, EventHandlersRouter
|
||||
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
|
||||
from fastapi_users.router.common import run_handler
|
||||
|
||||
|
||||
def _add_login_route(
|
||||
router: EventHandlersRouter,
|
||||
user_db: BaseUserDatabase,
|
||||
auth_backend: BaseAuthentication,
|
||||
):
|
||||
@router.post(f"/login/{auth_backend.name}")
|
||||
async def login(
|
||||
response: Response, credentials: OAuth2PasswordRequestForm = Depends()
|
||||
):
|
||||
user = await user_db.authenticate(credentials)
|
||||
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.LOGIN_BAD_CREDENTIALS,
|
||||
)
|
||||
|
||||
return await auth_backend.get_login_response(user, response)
|
||||
|
||||
|
||||
def _add_logout_route(
|
||||
router: EventHandlersRouter,
|
||||
authenticator: Authenticator,
|
||||
auth_backend: BaseAuthentication,
|
||||
):
|
||||
@router.post(f"/logout/{auth_backend.name}")
|
||||
async def logout(
|
||||
response: Response, user=Depends(authenticator.get_current_active_user)
|
||||
):
|
||||
try:
|
||||
return await auth_backend.get_logout_response(user, response)
|
||||
except NotImplementedError:
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
|
||||
|
||||
def get_user_router(
|
||||
def get_users_router(
|
||||
user_db: BaseUserDatabase[models.BaseUserDB],
|
||||
user_model: Type[models.BaseUser],
|
||||
user_create_model: Type[models.BaseUserCreate],
|
||||
user_update_model: Type[models.BaseUserUpdate],
|
||||
user_db_model: Type[models.BaseUserDB],
|
||||
authenticator: Authenticator,
|
||||
reset_password_token_secret: str,
|
||||
reset_password_token_lifetime_seconds: int = 3600,
|
||||
) -> EventHandlersRouter:
|
||||
after_update: Optional[Callable[[models.UD, Dict[str, Any], Request], None]] = None,
|
||||
) -> APIRouter:
|
||||
"""Generate a router with the authentication routes."""
|
||||
router = EventHandlersRouter()
|
||||
|
||||
reset_password_token_audience = "fastapi-users:reset"
|
||||
router = APIRouter()
|
||||
|
||||
get_current_active_user = authenticator.get_current_active_user
|
||||
get_current_superuser = authenticator.get_current_superuser
|
||||
@@ -75,99 +30,19 @@ def get_user_router(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return user
|
||||
|
||||
async def _update_user(user: models.BaseUserDB, update_dict: Dict[str, Any]):
|
||||
async def _update_user(
|
||||
user: models.BaseUserDB, update_dict: Dict[str, Any], request: Request
|
||||
):
|
||||
for field in update_dict:
|
||||
if field == "password":
|
||||
hashed_password = get_password_hash(update_dict[field])
|
||||
user.hashed_password = hashed_password
|
||||
else:
|
||||
setattr(user, field, update_dict[field])
|
||||
return await user_db.update(user)
|
||||
|
||||
for auth_backend in authenticator.backends:
|
||||
_add_login_route(router, user_db, auth_backend)
|
||||
_add_logout_route(router, authenticator, auth_backend)
|
||||
|
||||
@router.post(
|
||||
"/register", response_model=user_model, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def register(request: Request, user: user_create_model): # type: ignore
|
||||
user = cast(models.BaseUserCreate, user) # Prevent mypy complain
|
||||
existing_user = await user_db.get_by_email(user.email)
|
||||
|
||||
if existing_user is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.REGISTER_USER_ALREADY_EXISTS,
|
||||
)
|
||||
|
||||
hashed_password = get_password_hash(user.password)
|
||||
db_user = user_db_model(
|
||||
**user.create_update_dict(), hashed_password=hashed_password
|
||||
)
|
||||
created_user = await user_db.create(db_user)
|
||||
|
||||
await router.run_handlers(Event.ON_AFTER_REGISTER, created_user, request)
|
||||
|
||||
return created_user
|
||||
|
||||
@router.post("/forgot-password", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def forgot_password(
|
||||
request: Request, email: EmailStr = Body(..., embed=True)
|
||||
):
|
||||
user = await user_db.get_by_email(email)
|
||||
|
||||
if user is not None and user.is_active:
|
||||
token_data = {"user_id": str(user.id), "aud": reset_password_token_audience}
|
||||
token = generate_jwt(
|
||||
token_data,
|
||||
reset_password_token_lifetime_seconds,
|
||||
reset_password_token_secret,
|
||||
)
|
||||
await router.run_handlers(
|
||||
Event.ON_AFTER_FORGOT_PASSWORD, user, token, request
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(token: str = Body(...), password: str = Body(...)):
|
||||
try:
|
||||
data = jwt.decode(
|
||||
token,
|
||||
reset_password_token_secret,
|
||||
audience=reset_password_token_audience,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
)
|
||||
user_id = data.get("user_id")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
|
||||
)
|
||||
|
||||
try:
|
||||
user_uiid = UUID4(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
|
||||
)
|
||||
|
||||
user = await user_db.get(user_uiid)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
|
||||
)
|
||||
|
||||
user.hashed_password = get_password_hash(password)
|
||||
await user_db.update(user)
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
|
||||
)
|
||||
updated_user = await user_db.update(user)
|
||||
if after_update:
|
||||
await run_handler(after_update, updated_user, update_dict, request)
|
||||
return updated_user
|
||||
|
||||
@router.get("/me", response_model=user_model)
|
||||
async def me(
|
||||
@@ -185,11 +60,7 @@ def get_user_router(
|
||||
models.BaseUserUpdate, updated_user,
|
||||
) # Prevent mypy complain
|
||||
updated_user_data = updated_user.create_update_dict()
|
||||
updated_user = await _update_user(user, updated_user_data)
|
||||
|
||||
await router.run_handlers(
|
||||
Event.ON_AFTER_UPDATE, updated_user, updated_user_data, request
|
||||
)
|
||||
updated_user = await _update_user(user, updated_user_data, request)
|
||||
|
||||
return updated_user
|
||||
|
||||
@@ -207,14 +78,14 @@ def get_user_router(
|
||||
dependencies=[Depends(get_current_superuser)],
|
||||
)
|
||||
async def update_user(
|
||||
id: UUID4, updated_user: user_update_model, # type: ignore
|
||||
id: UUID4, updated_user: user_update_model, request: Request # type: ignore
|
||||
):
|
||||
updated_user = cast(
|
||||
models.BaseUserUpdate, updated_user,
|
||||
) # Prevent mypy complain
|
||||
user = await _get_or_404(id)
|
||||
updated_user_data = updated_user.create_update_dict_superuser()
|
||||
return await _update_user(user, updated_user_data)
|
||||
return await _update_user(user, updated_user_data, request)
|
||||
|
||||
@router.delete(
|
||||
"/{id}",
|
||||
|
||||
Reference in New Issue
Block a user