From 05b1df9a1677f4d0aaa761f35530f10d49edf7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Mon, 3 Feb 2020 10:12:33 +0100 Subject: [PATCH] Implement logout route --- docs/configuration/authentication/cookie.md | 8 +++ docs/configuration/authentication/index.md | 2 + docs/configuration/authentication/jwt.md | 8 +++ docs/src/full_sqlalchemy.py | 77 +++++++++++++++++++++ docs/src/full_tortoise.py | 55 +++++++++++++++ docs/usage/routes.md | 13 ++++ fastapi_users/authentication/base.py | 3 + fastapi_users/authentication/cookie.py | 5 ++ fastapi_users/router/users.py | 16 +++++ tests/test_authentication_base.py | 7 ++ tests/test_authentication_cookie.py | 18 +++++ tests/test_authentication_jwt.py | 7 ++ tests/test_router_users.py | 19 +++++ 13 files changed, 238 insertions(+) create mode 100644 docs/src/full_sqlalchemy.py create mode 100644 docs/src/full_tortoise.py diff --git a/docs/configuration/authentication/cookie.md b/docs/configuration/authentication/cookie.md index 814afd69..7fe8537d 100644 --- a/docs/configuration/authentication/cookie.md +++ b/docs/configuration/authentication/cookie.md @@ -47,6 +47,14 @@ This method will return a response with a valid `set-cookie` header upon success > Check documentation about [login route](../../usage/routes.md#post-loginname). +## Logout + +This method will remove the authentication cookie: + +!!! success "`200 OK`" + +> Check documentation about [logout route](../../usage/routes.md#post-logoutname). + ## Authentication This method expects that you provide a valid cookie in the headers. diff --git a/docs/configuration/authentication/index.md b/docs/configuration/authentication/index.md index 0359c933..14868b91 100644 --- a/docs/configuration/authentication/index.md +++ b/docs/configuration/authentication/index.md @@ -10,6 +10,8 @@ When checking authentication, each method is run one after the other. The first Each defined method will generate a [`/login/{name}`](../../usage/routes.md#post-loginname) route where `name` is defined on the authentication method object. +Each defined method will generate a [`/logout/{name}`](../../usage/routes.md#post-logoutname) route where `name` is defined on the authentication method object. + ## Provided methods * [JWT authentication](jwt.md) diff --git a/docs/configuration/authentication/jwt.md b/docs/configuration/authentication/jwt.md index 39831e5a..5c84dbd5 100644 --- a/docs/configuration/authentication/jwt.md +++ b/docs/configuration/authentication/jwt.md @@ -41,6 +41,14 @@ This method will return a JWT token upon successful login: > Check documentation about [login route](../../usage/routes.md#post-loginname). +## Logout + +This method is not applicable to this backend and won't do anything. + +!!! success "`202 Accepted`" + +> Check documentation about [logout route](../../usage/routes.md#post-logoutname). + ## Authentication This method expects that you provide a `Bearer` authentication with a valid JWT. diff --git a/docs/src/full_sqlalchemy.py b/docs/src/full_sqlalchemy.py new file mode 100644 index 00000000..ef4a4859 --- /dev/null +++ b/docs/src/full_sqlalchemy.py @@ -0,0 +1,77 @@ +import databases +import sqlalchemy +from fastapi import FastAPI +from fastapi_users import FastAPIUsers, models +from fastapi_users.authentication import JWTAuthentication +from fastapi_users.db import ( + SQLAlchemyBaseUserTable, + SQLAlchemyUserDatabase, +) +from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base + +DATABASE_URL = "sqlite:///./test.db" +SECRET = "SECRET" + + +class User(models.BaseUser): + pass + + +class UserCreate(User, models.BaseUserCreate): + pass + + +class UserUpdate(User, models.BaseUserUpdate): + pass + + +class UserDB(User, models.BaseUserDB): + pass + + +database = databases.Database(DATABASE_URL) +Base: DeclarativeMeta = declarative_base() + + +class UserTable(Base, SQLAlchemyBaseUserTable): + pass + + +engine = sqlalchemy.create_engine( + DATABASE_URL, connect_args={"check_same_thread": False} +) +Base.metadata.create_all(engine) + +users = UserTable.__table__ +user_db = SQLAlchemyUserDatabase(UserDB, database, users) + + +auth_backends = [ + JWTAuthentication(secret=SECRET, lifetime_seconds=3600), +] + +app = FastAPI() +fastapi_users = FastAPIUsers( + user_db, auth_backends, User, UserCreate, UserUpdate, UserDB, SECRET, +) +app.include_router(fastapi_users.router, prefix="/users", tags=["users"]) + + +@fastapi_users.on_after_register() +def on_after_register(user: User): + print(f"User {user.id} has registered.") + + +@fastapi_users.on_after_forgot_password() +def on_after_forgot_password(user: User, token: str): + print(f"User {user.id} has forgot their password. Reset token: {token}") + + +@app.on_event("startup") +async def startup(): + await database.connect() + + +@app.on_event("shutdown") +async def shutdown(): + await database.disconnect() diff --git a/docs/src/full_tortoise.py b/docs/src/full_tortoise.py new file mode 100644 index 00000000..9b8f2952 --- /dev/null +++ b/docs/src/full_tortoise.py @@ -0,0 +1,55 @@ +from fastapi import FastAPI +from fastapi_users import FastAPIUsers, models +from fastapi_users.authentication import JWTAuthentication +from fastapi_users.db import ( + TortoiseBaseUserModel, + TortoiseUserDatabase, +) +from tortoise.contrib.starlette import register_tortoise + +DATABASE_URL = "sqlite://./test.db" +SECRET = "SECRET" + + +class User(models.BaseUser): + pass + + +class UserCreate(User, models.BaseUserCreate): + pass + + +class UserUpdate(User, models.BaseUserUpdate): + pass + + +class UserDB(User, models.BaseUserDB): + pass + + +class UserModel(TortoiseBaseUserModel): + pass + + +user_db = TortoiseUserDatabase(UserDB, UserModel) +app = FastAPI() +register_tortoise(app, db_url=DATABASE_URL, modules={"models": ["test"]}) + +auth_backends = [ + JWTAuthentication(secret=SECRET, lifetime_seconds=3600), +] + +fastapi_users = FastAPIUsers( + user_db, auth_backends, User, UserCreate, UserUpdate, UserDB, SECRET, +) +app.include_router(fastapi_users.router, prefix="/users", tags=["users"]) + + +@fastapi_users.on_after_register() +def on_after_register(user: User): + print(f"User {user.id} has registered.") + + +@fastapi_users.on_after_forgot_password() +def on_after_forgot_password(user: User, token: str): + print(f"User {user.id} has forgot their password. Reset token: {token}") diff --git a/docs/usage/routes.md b/docs/usage/routes.md index 703e5f01..53b459c2 100644 --- a/docs/usage/routes.md +++ b/docs/usage/routes.md @@ -57,6 +57,19 @@ Login a user against the method named `name`. Check the corresponding [authentic } ``` +### `POST /logout/{name}` + +Logout the authenticated user against the method named `name`. Check the corresponding [authentication method](../configuration/authentication/index.md) to view the success response. + +!!! fail "`401 Unauthorized`" + Missing token or inactive user. + +!!! success "`200 OK`" + The logout process was successful. + +!!! success "`202 Accepted`" + The logout process is not applicable for this authentication backend (e.g. JWT). + ### `POST /forgot-password` Request a reset password procedure. Will generate a temporary token and call the `on_after_forgot_password` [event handlers](../configuration/router.md#event-handlers) if the user exists. diff --git a/fastapi_users/authentication/base.py b/fastapi_users/authentication/base.py index 799b579d..79fdca02 100644 --- a/fastapi_users/authentication/base.py +++ b/fastapi_users/authentication/base.py @@ -28,3 +28,6 @@ class BaseAuthentication: async def get_login_response(self, user: BaseUserDB, response: Response) -> Any: raise NotImplementedError() + + async def get_logout_response(self, user: BaseUserDB, response: Response) -> Any: + raise NotImplementedError() diff --git a/fastapi_users/authentication/cookie.py b/fastapi_users/authentication/cookie.py index c1a20c5a..6610defb 100644 --- a/fastapi_users/authentication/cookie.py +++ b/fastapi_users/authentication/cookie.py @@ -67,5 +67,10 @@ class CookieAuthentication(JWTAuthentication): # so that FastAPI can terminate it properly return None + async def get_logout_response(self, user: BaseUserDB, response: Response) -> Any: + response.delete_cookie( + 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) diff --git a/fastapi_users/router/users.py b/fastapi_users/router/users.py index ea2d3dad..9ea69ee3 100644 --- a/fastapi_users/router/users.py +++ b/fastapi_users/router/users.py @@ -36,6 +36,21 @@ def _add_login_route( 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( user_db: BaseUserDatabase[models.BaseUserDB], user_model: Type[models.BaseUser], @@ -71,6 +86,7 @@ def get_user_router( 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 diff --git a/tests/test_authentication_base.py b/tests/test_authentication_base.py index 72ae626d..849086ed 100644 --- a/tests/test_authentication_base.py +++ b/tests/test_authentication_base.py @@ -25,3 +25,10 @@ class TestAuthenticate: async def test_get_login_response(base_authentication, user): with pytest.raises(NotImplementedError): await base_authentication.get_login_response(user, Response()) + + +@pytest.mark.authentication +@pytest.mark.asyncio +async def test_get_logout_response(base_authentication, user): + with pytest.raises(NotImplementedError): + await base_authentication.get_logout_response(user, Response()) diff --git a/tests/test_authentication_cookie.py b/tests/test_authentication_cookie.py index e61d8230..4a6866de 100644 --- a/tests/test_authentication_cookie.py +++ b/tests/test_authentication_cookie.py @@ -132,3 +132,21 @@ async def test_get_login_response( cookie_value, SECRET, audience="fastapi-users:auth", algorithms=[JWT_ALGORITHM] ) assert decoded["user_id"] == user.id + + +@pytest.mark.authentication +@pytest.mark.asyncio +async def test_get_logout_response(user): + response = Response() + logout_response = await cookie_authentication.get_logout_response(user, response) + + # We shouldn't return directly the response + # so that FastAPI can terminate it properly + assert logout_response is None + + cookies = [header for header in response.raw_headers if header[0] == b"set-cookie"] + assert len(cookies) == 1 + + cookie = cookies[0][1].decode("latin-1") + + assert f"Max-Age=0" in cookie diff --git a/tests/test_authentication_jwt.py b/tests/test_authentication_jwt.py index cff8751d..bd6845f1 100644 --- a/tests/test_authentication_jwt.py +++ b/tests/test_authentication_jwt.py @@ -78,3 +78,10 @@ async def test_get_login_response(jwt_authentication, user): token, SECRET, audience="fastapi-users:auth", algorithms=[JWT_ALGORITHM] ) assert decoded["user_id"] == user.id + + +@pytest.mark.authentication +@pytest.mark.asyncio +async def test_get_logout_response(jwt_authentication, user): + with pytest.raises(NotImplementedError): + await jwt_authentication.get_logout_response(user, Response()) diff --git a/tests/test_router_users.py b/tests/test_router_users.py index 7ca4e085..e533255a 100644 --- a/tests/test_router_users.py +++ b/tests/test_router_users.py @@ -179,6 +179,25 @@ class TestLogin: assert response.json()["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS +@pytest.mark.router +@pytest.mark.parametrize("path", ["/logout/mock", "/logout/mock-bis"]) +class TestLogout: + def test_missing_token(self, path, test_app_client: TestClient): + response = test_app_client.post(path) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_unimplemented_logout( + self, mocker, path, test_app_client: TestClient, user: UserDB + ): + get_logout_response_spy = mocker.spy(MockAuthentication, "get_logout_response") + response = test_app_client.post( + path, headers={"Authorization": f"Bearer {user.id}"} + ) + assert response.status_code == status.HTTP_202_ACCEPTED + + get_logout_response_spy.assert_called_once() + + @pytest.mark.router class TestForgotPassword: def test_empty_body(self, test_app_client: TestClient, event_handler):