Revamp Transport so they always build a full Response object (#1049)

* Revamp Transport so they always build a full Response object

* Fix linting

* Add private methods to set cookies on CookieTransport

* Change on_after_login login_return parameter to response
This commit is contained in:
François Voron
2023-04-27 09:32:49 +02:00
committed by GitHub
parent 9a2515f56c
commit 8fd097cbc8
12 changed files with 65 additions and 71 deletions

View File

@@ -187,7 +187,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
self, self,
user: User, user: User,
request: Optional[Request] = None, request: Optional[Request] = None,
login_return: Optional[Any] = None, response: Optional[Response] = None,
): ):
print(f"User {user.id} logged in.") print(f"User {user.id} logged in.")
``` ```

View File

@@ -1,6 +1,6 @@
from typing import Any, Generic from typing import Generic
from fastapi import Response from fastapi import Response, status
from fastapi_users import models from fastapi_users import models
from fastapi_users.authentication.strategy import ( from fastapi_users.authentication.strategy import (
@@ -40,27 +40,22 @@ class AuthenticationBackend(Generic[models.UP, models.ID]):
self.get_strategy = get_strategy self.get_strategy = get_strategy
async def login( async def login(
self, self, strategy: Strategy[models.UP, models.ID], user: models.UP
strategy: Strategy[models.UP, models.ID], ) -> Response:
user: models.UP,
response: Response,
) -> Any:
token = await strategy.write_token(user) token = await strategy.write_token(user)
return await self.transport.get_login_response(token, response) return await self.transport.get_login_response(token)
async def logout( async def logout(
self, self, strategy: Strategy[models.UP, models.ID], user: models.UP, token: str
strategy: Strategy[models.UP, models.ID], ) -> Response:
user: models.UP,
token: str,
response: Response,
) -> Any:
try: try:
await strategy.destroy_token(token, user) await strategy.destroy_token(token, user)
except StrategyDestroyNotSupportedError: except StrategyDestroyNotSupportedError:
pass pass
try: try:
await self.transport.get_logout_response(response) response = await self.transport.get_logout_response()
except TransportLogoutNotSupportedError: except TransportLogoutNotSupportedError:
return None response = Response(status_code=status.HTTP_204_NO_CONTENT)
return response

View File

@@ -1,5 +1,4 @@
import sys import sys
from typing import Any
if sys.version_info < (3, 8): if sys.version_info < (3, 8):
from typing_extensions import Protocol # pragma: no cover from typing_extensions import Protocol # pragma: no cover
@@ -19,10 +18,10 @@ class TransportLogoutNotSupportedError(Exception):
class Transport(Protocol): class Transport(Protocol):
scheme: SecurityBase scheme: SecurityBase
async def get_login_response(self, token: str, response: Response) -> Any: async def get_login_response(self, token: str) -> Response:
... # pragma: no cover ... # pragma: no cover
async def get_logout_response(self, response: Response) -> Any: async def get_logout_response(self) -> Response:
... # pragma: no cover ... # pragma: no cover
@staticmethod @staticmethod

View File

@@ -1,6 +1,5 @@
from typing import Any
from fastapi import Response, status from fastapi import Response, status
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel from pydantic import BaseModel
@@ -22,10 +21,11 @@ class BearerTransport(Transport):
def __init__(self, tokenUrl: str): def __init__(self, tokenUrl: str):
self.scheme = OAuth2PasswordBearer(tokenUrl, auto_error=False) self.scheme = OAuth2PasswordBearer(tokenUrl, auto_error=False)
async def get_login_response(self, token: str, response: Response) -> Any: async def get_login_response(self, token: str) -> Response:
return BearerResponse(access_token=token, token_type="bearer") bearer_response = BearerResponse(access_token=token, token_type="bearer")
return JSONResponse(bearer_response.dict())
async def get_logout_response(self, response: Response) -> Any: async def get_logout_response(self) -> Response:
raise TransportLogoutNotSupportedError() raise TransportLogoutNotSupportedError()
@staticmethod @staticmethod

View File

@@ -1,5 +1,5 @@
import sys import sys
from typing import Any, Optional from typing import Optional
if sys.version_info < (3, 8): if sys.version_info < (3, 8):
from typing_extensions import Literal # pragma: no cover from typing_extensions import Literal # pragma: no cover
@@ -35,7 +35,15 @@ class CookieTransport(Transport):
self.cookie_samesite = cookie_samesite self.cookie_samesite = cookie_samesite
self.scheme = APIKeyCookie(name=self.cookie_name, auto_error=False) self.scheme = APIKeyCookie(name=self.cookie_name, auto_error=False)
async def get_login_response(self, token: str, response: Response) -> Any: async def get_login_response(self, token: str) -> Response:
response = Response(status_code=status.HTTP_204_NO_CONTENT)
return self._set_login_cookie(response, token)
async def get_logout_response(self) -> Response:
response = Response(status_code=status.HTTP_204_NO_CONTENT)
return self._set_logout_cookie(response)
def _set_login_cookie(self, response: Response, token: str) -> Response:
response.set_cookie( response.set_cookie(
self.cookie_name, self.cookie_name,
token, token,
@@ -46,12 +54,9 @@ class CookieTransport(Transport):
httponly=self.cookie_httponly, httponly=self.cookie_httponly,
samesite=self.cookie_samesite, samesite=self.cookie_samesite,
) )
return response
# We shouldn't return directly the response def _set_logout_cookie(self, response: Response) -> Response:
# so that FastAPI can terminate it properly
return None
async def get_logout_response(self, response: Response) -> Any:
response.set_cookie( response.set_cookie(
self.cookie_name, self.cookie_name,
"", "",
@@ -62,11 +67,12 @@ class CookieTransport(Transport):
httponly=self.cookie_httponly, httponly=self.cookie_httponly,
samesite=self.cookie_samesite, samesite=self.cookie_samesite,
) )
return response
@staticmethod @staticmethod
def get_openapi_login_responses_success() -> OpenAPIResponseType: def get_openapi_login_responses_success() -> OpenAPIResponseType:
return {status.HTTP_200_OK: {"model": None}} return {status.HTTP_204_NO_CONTENT: {"model": None}}
@staticmethod @staticmethod
def get_openapi_logout_responses_success() -> OpenAPIResponseType: def get_openapi_logout_responses_success() -> OpenAPIResponseType:
return {status.HTTP_200_OK: {"model": None}} return {status.HTTP_204_NO_CONTENT: {"model": None}}

View File

@@ -2,7 +2,7 @@ import uuid
from typing import Any, Dict, Generic, Optional, Union from typing import Any, Dict, Generic, Optional, Union
import jwt import jwt
from fastapi import Request from fastapi import Request, Response
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from fastapi_users import exceptions, models, schemas from fastapi_users import exceptions, models, schemas
@@ -589,7 +589,7 @@ class BaseUserManager(Generic[models.UP, models.ID]):
self, self,
user: models.UP, user: models.UP,
request: Optional[Request] = None, request: Optional[Request] = None,
login_return: Optional[Any] = None, response: Optional[Response] = None,
) -> None: ) -> None:
""" """
Perform logic after user login. Perform logic after user login.
@@ -598,8 +598,8 @@ class BaseUserManager(Generic[models.UP, models.ID]):
:param user: The user that is logging in :param user: The user that is logging in
:param request: Optional FastAPI request :param request: Optional FastAPI request
:param login_return: Optional return of the login :param response: Optional response built by the transport.
triggered the operation, defaults to None. Defaults to None
""" """
return # pragma: no cover return # pragma: no cover

View File

@@ -1,6 +1,6 @@
from typing import Tuple from typing import Tuple
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from fastapi_users import models from fastapi_users import models
@@ -50,7 +50,6 @@ def get_auth_router(
) )
async def login( async def login(
request: Request, request: Request,
response: Response,
credentials: OAuth2PasswordRequestForm = Depends(), credentials: OAuth2PasswordRequestForm = Depends(),
user_manager: BaseUserManager[models.UP, models.ID] = Depends(get_user_manager), user_manager: BaseUserManager[models.UP, models.ID] = Depends(get_user_manager),
strategy: Strategy[models.UP, models.ID] = Depends(backend.get_strategy), strategy: Strategy[models.UP, models.ID] = Depends(backend.get_strategy),
@@ -67,9 +66,9 @@ def get_auth_router(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.LOGIN_USER_NOT_VERIFIED, detail=ErrorCode.LOGIN_USER_NOT_VERIFIED,
) )
login_return = await backend.login(strategy, user, response) response = await backend.login(strategy, user)
await user_manager.on_after_login(user, request, login_return) await user_manager.on_after_login(user, request, response)
return login_return return response
logout_responses: OpenAPIResponseType = { logout_responses: OpenAPIResponseType = {
**{ **{
@@ -84,11 +83,10 @@ def get_auth_router(
"/logout", name=f"auth:{backend.name}.logout", responses=logout_responses "/logout", name=f"auth:{backend.name}.logout", responses=logout_responses
) )
async def logout( async def logout(
response: Response,
user_token: Tuple[models.UP, str] = Depends(get_current_user_token), user_token: Tuple[models.UP, str] = Depends(get_current_user_token),
strategy: Strategy[models.UP, models.ID] = Depends(backend.get_strategy), strategy: Strategy[models.UP, models.ID] = Depends(backend.get_strategy),
): ):
user, token = user_token user, token = user_token
return await backend.logout(strategy, user, token, response) return await backend.logout(strategy, user, token)
return router return router

View File

@@ -1,7 +1,7 @@
from typing import Dict, List, Optional, Tuple, Type from typing import Dict, List, Optional, Tuple, Type
import jwt import jwt
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback
from httpx_oauth.oauth2 import BaseOAuth2, OAuth2Token from httpx_oauth.oauth2 import BaseOAuth2, OAuth2Token
from pydantic import BaseModel from pydantic import BaseModel
@@ -100,7 +100,6 @@ def get_oauth_router(
) )
async def callback( async def callback(
request: Request, request: Request,
response: Response,
access_token_state: Tuple[OAuth2Token, str] = Depends( access_token_state: Tuple[OAuth2Token, str] = Depends(
oauth2_authorize_callback oauth2_authorize_callback
), ),
@@ -148,9 +147,9 @@ def get_oauth_router(
) )
# Authenticate # Authenticate
login_return = await backend.login(strategy, user, response) response = await backend.login(strategy, user)
await user_manager.on_after_login(user, request, login_return) await user_manager.on_after_login(user, request, response)
return login_return return response
return router return router

View File

@@ -517,8 +517,8 @@ class MockTransport(BearerTransport):
def __init__(self, tokenUrl: str): def __init__(self, tokenUrl: str):
super().__init__(tokenUrl) super().__init__(tokenUrl)
async def get_logout_response(self, response: Response) -> Any: async def get_logout_response(self) -> Any:
return None return Response()
@staticmethod @staticmethod
def get_openapi_logout_responses_success() -> OpenAPIResponseType: def get_openapi_logout_responses_success() -> OpenAPIResponseType:

View File

@@ -57,5 +57,5 @@ def backend(
@pytest.mark.authentication @pytest.mark.authentication
async def test_logout(backend: AuthenticationBackend, user: UserModel): async def test_logout(backend: AuthenticationBackend, user: UserModel):
strategy = cast(Strategy, backend.get_strategy()) strategy = cast(Strategy, backend.get_strategy())
result = await backend.logout(strategy, user, "TOKEN", Response()) result = await backend.logout(strategy, user, "TOKEN")
assert result is None assert isinstance(result, Response)

View File

@@ -1,5 +1,6 @@
import pytest import pytest
from fastapi import Response, status from fastapi import status
from fastapi.responses import JSONResponse
from fastapi_users.authentication.transport import ( from fastapi_users.authentication.transport import (
BearerTransport, BearerTransport,
@@ -16,21 +17,17 @@ def bearer_transport() -> BearerTransport:
@pytest.mark.authentication @pytest.mark.authentication
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_login_response(bearer_transport: BearerTransport): async def test_get_login_response(bearer_transport: BearerTransport):
response = Response() response = await bearer_transport.get_login_response("TOKEN")
login_response = await bearer_transport.get_login_response("TOKEN", response)
assert isinstance(login_response, BearerResponse) assert isinstance(response, JSONResponse)
assert response.body == b'{"access_token":"TOKEN","token_type":"bearer"}'
assert login_response.access_token == "TOKEN"
assert login_response.token_type == "bearer"
@pytest.mark.authentication @pytest.mark.authentication
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_logout_response(bearer_transport: BearerTransport): async def test_get_logout_response(bearer_transport: BearerTransport):
response = Response()
with pytest.raises(TransportLogoutNotSupportedError): with pytest.raises(TransportLogoutNotSupportedError):
await bearer_transport.get_logout_response(response) await bearer_transport.get_logout_response()
@pytest.mark.authentication @pytest.mark.authentication

View File

@@ -38,10 +38,10 @@ async def test_get_login_response(cookie_transport: CookieTransport):
secure = cookie_transport.cookie_secure secure = cookie_transport.cookie_secure
httponly = cookie_transport.cookie_httponly httponly = cookie_transport.cookie_httponly
response = Response() response = await cookie_transport.get_login_response("TOKEN")
login_response = await cookie_transport.get_login_response("TOKEN", response)
assert login_response is None assert isinstance(response, Response)
assert response.status_code == status.HTTP_204_NO_CONTENT
cookies = [header for header in response.raw_headers if header[0] == b"set-cookie"] cookies = [header for header in response.raw_headers if header[0] == b"set-cookie"]
assert len(cookies) == 1 assert len(cookies) == 1
@@ -79,10 +79,10 @@ async def test_get_login_response(cookie_transport: CookieTransport):
@pytest.mark.authentication @pytest.mark.authentication
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_logout_response(cookie_transport: CookieTransport): async def test_get_logout_response(cookie_transport: CookieTransport):
response = Response() response = await cookie_transport.get_logout_response()
logout_response = await cookie_transport.get_logout_response(response)
assert logout_response is None assert isinstance(response, Response)
assert response.status_code == status.HTTP_204_NO_CONTENT
cookies = [header for header in response.raw_headers if header[0] == b"set-cookie"] cookies = [header for header in response.raw_headers if header[0] == b"set-cookie"]
assert len(cookies) == 1 assert len(cookies) == 1
@@ -96,7 +96,7 @@ async def test_get_logout_response(cookie_transport: CookieTransport):
@pytest.mark.openapi @pytest.mark.openapi
def test_get_openapi_login_responses_success(cookie_transport: CookieTransport): def test_get_openapi_login_responses_success(cookie_transport: CookieTransport):
assert cookie_transport.get_openapi_login_responses_success() == { assert cookie_transport.get_openapi_login_responses_success() == {
status.HTTP_200_OK: {"model": None} status.HTTP_204_NO_CONTENT: {"model": None}
} }
@@ -104,5 +104,5 @@ def test_get_openapi_login_responses_success(cookie_transport: CookieTransport):
@pytest.mark.openapi @pytest.mark.openapi
def test_get_openapi_logout_responses_success(cookie_transport: CookieTransport): def test_get_openapi_logout_responses_success(cookie_transport: CookieTransport):
assert cookie_transport.get_openapi_logout_responses_success() == { assert cookie_transport.get_openapi_logout_responses_success() == {
status.HTTP_200_OK: {"model": None} status.HTTP_204_NO_CONTENT: {"model": None}
} }