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)

View File

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

View File

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

View 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

View File

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

View File

@@ -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 = []

View 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

View 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

View File

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