From 008a8296f22ef9a9d2956de5da6f51c4de63ec1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Thu, 24 Oct 2019 09:18:07 +0200 Subject: [PATCH 1/2] Define on_after_forgot_password with a decorator --- docs/configuration/router.md | 43 +++++++++++++++++----------------- docs/src/full_sqlalchemy.py | 10 ++++---- fastapi_users/fastapi_users.py | 24 ++++++++++++------- fastapi_users/router.py | 43 ++++++++++++++++++++++++---------- tests/test_fastapi_users.py | 9 ++++--- tests/test_router.py | 13 +++++----- 6 files changed, 83 insertions(+), 59 deletions(-) diff --git a/docs/configuration/router.md b/docs/configuration/router.md index 6c271c6d..465c2ff2 100644 --- a/docs/configuration/router.md +++ b/docs/configuration/router.md @@ -2,33 +2,13 @@ We're almost there! The last step is to configure the `FastAPIUsers` object that will wire the database adapter, the authentication class and the user model to expose the FastAPI router. -## Hooks - -In order to be as unopinionated as possible, you'll have to define your logic after some actions. - -### After forgot password - -This hook is called after a successful forgot password request. It is called with **two arguments**: the **user** which has requested to reset their password and a ready-to-use **JWT token** that will be accepted by the reset password route. - -Typically, you'll want to **send an e-mail** with the link (and the token) that allows the user to reset their password. - -You can define it as an `async` or standard method. - -Example: - -```py -def on_after_forgot_password(user, token): - print(f'User {user.id} has forgot their password. Reset token: {token}') -``` - ## Configure `FastAPIUsers` -The last step is to instantiate `FastAPIUsers` object with all the elements we defined before. More precisely: +Configure `FastAPIUsers` object with all the elements we defined before. More precisely: * `db`: Database adapter instance. * `auth`: Authentication logic instance. * `user_model`: Pydantic model of a user. -* `on_after_forgot_password`: Hook called after a forgot password request. * `reset_password_token_secret`: Secret to encode reset password token. * `reset_password_token_lifetime_seconds`: Lifetime of reset password token in seconds. Default to one hour. @@ -39,7 +19,6 @@ fastapi_users = FastAPIUsers( user_db, auth, User, - on_after_forgot_password, SECRET, ) ``` @@ -51,6 +30,26 @@ app = FastAPI() app.include_router(fastapi_users.router, prefix="/users", tags=["users"]) ``` +## Event handlers + +In order to be as unopinionated as possible, we expose decorators that allow you to plug your own logic after some actions. You can have several handlers per event. + +### After forgot password + +This event handler is called after a successful forgot password request. It is called with **two arguments**: the **user** which has requested to reset their password and a ready-to-use **JWT token** that will be accepted by the reset password route. + +Typically, you'll want to **send an e-mail** with the link (and the token) that allows the user to reset their password. + +You can define it as an `async` or standard method. + +Example: + +```py +@fastapi_users.on_after_forgot_password() +def on_after_forgot_password(user, token): + print(f'User {user.id} has forgot their password. Reset token: {token}') +``` + ## Next steps Check out a [full example](full_example.md) that will show you the big picture. diff --git a/docs/src/full_sqlalchemy.py b/docs/src/full_sqlalchemy.py index 9e8a62bc..14fac916 100644 --- a/docs/src/full_sqlalchemy.py +++ b/docs/src/full_sqlalchemy.py @@ -35,16 +35,16 @@ class User(BaseUser): auth = JWTAuthentication(secret=SECRET, lifetime_seconds=3600) +app = FastAPI() +fastapi_users = FastAPIUsers(user_db, auth, User, SECRET) +app.include_router(fastapi_users.router, prefix="/users", tags=["users"]) + +@fastapi_users.on_after_forgot_password() def on_after_forgot_password(user, token): print(f"User {user.id} has forgot their password. Reset token: {token}") -app = FastAPI() -fastapi_users = FastAPIUsers(user_db, auth, User, on_after_forgot_password, SECRET) -app.include_router(fastapi_users.router, prefix="/users", tags=["users"]) - - @app.on_event("startup") async def startup(): await database.connect() diff --git a/fastapi_users/fastapi_users.py b/fastapi_users/fastapi_users.py index 4715d63b..5a11e072 100644 --- a/fastapi_users/fastapi_users.py +++ b/fastapi_users/fastapi_users.py @@ -1,11 +1,9 @@ -from typing import Any, Callable, Type - -from fastapi import APIRouter +from typing import Callable, Type from fastapi_users.authentication import BaseAuthentication from fastapi_users.db import BaseUserDatabase from fastapi_users.models import BaseUser, BaseUserDB -from fastapi_users.router import get_user_router +from fastapi_users.router import Events, UserRouter, get_user_router class FastAPIUsers: @@ -15,17 +13,16 @@ class FastAPIUsers: :param db: Database adapter instance. :param auth: Authentication logic instance. :param user_model: Pydantic model of a user. - :param on_after_forgot_password: Hook called after a forgot password request. :param reset_password_token_secret: Secret to encode reset password token. :param reset_password_token_lifetime_seconds: Lifetime of reset password token. - :attribute router: FastAPI router exposing authentication routes. + :attribute router: Router exposing authentication routes. :attribute get_current_user: Dependency callable to inject authenticated user. """ db: BaseUserDatabase auth: BaseAuthentication - router: APIRouter + router: UserRouter get_current_user: Callable[..., BaseUserDB] def __init__( @@ -33,7 +30,6 @@ class FastAPIUsers: db: BaseUserDatabase, auth: BaseAuthentication, user_model: Type[BaseUser], - on_after_forgot_password: Callable[[BaseUserDB, str], Any], reset_password_token_secret: str, reset_password_token_lifetime_seconds: int = 3600, ): @@ -43,7 +39,6 @@ class FastAPIUsers: self.db, user_model, self.auth, - on_after_forgot_password, reset_password_token_secret, reset_password_token_lifetime_seconds, ) @@ -56,3 +51,14 @@ class FastAPIUsers: get_current_superuser = self.auth.get_current_superuser(self.db) self.get_current_superuser = get_current_superuser # type: ignore + + def on_after_forgot_password(self) -> Callable: + """Add an event handler on successful forgot password request.""" + return self._on_event(Events.ON_AFTER_FORGOT_PASSWORD) + + def _on_event(self, event_type: Events) -> Callable: + def decorator(func: Callable) -> Callable: + self.router.add_event_handler(event_type, func) + return func + + return decorator diff --git a/fastapi_users/router.py b/fastapi_users/router.py index 3c2a9724..6175ede7 100644 --- a/fastapi_users/router.py +++ b/fastapi_users/router.py @@ -1,5 +1,7 @@ import asyncio -from typing import Any, Callable, Type +import typing +from collections import defaultdict +from enum import Enum import jwt from fastapi import APIRouter, Body, Depends, HTTPException @@ -10,27 +12,45 @@ from starlette.responses import Response from fastapi_users.authentication import BaseAuthentication from fastapi_users.db import BaseUserDatabase -from fastapi_users.models import BaseUser, BaseUserDB, Models +from fastapi_users.models import BaseUser, Models from fastapi_users.password import get_password_hash from fastapi_users.utils import JWT_ALGORITHM, generate_jwt +class Events(Enum): + ON_AFTER_FORGOT_PASSWORD = 1 + + +class UserRouter(APIRouter): + event_handlers: typing.DefaultDict[Events, typing.List[typing.Callable]] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.event_handlers = defaultdict(list) + + def add_event_handler(self, event_type: Events, func: typing.Callable) -> None: + self.event_handlers[event_type].append(func) + + async def run_handlers(self, event_type: Events, *args, **kwargs) -> None: + for handler in self.event_handlers[event_type]: + if asyncio.iscoroutinefunction(handler): + await handler(*args, **kwargs) + else: + handler(*args, **kwargs) + + def get_user_router( user_db: BaseUserDatabase, - user_model: Type[BaseUser], + user_model: typing.Type[BaseUser], auth: BaseAuthentication, - on_after_forgot_password: Callable[[BaseUserDB, str], Any], reset_password_token_secret: str, reset_password_token_lifetime_seconds: int = 3600, -) -> APIRouter: +) -> UserRouter: """Generate a router with the authentication routes.""" - router = APIRouter() + router = UserRouter() models = Models(user_model) reset_password_token_audience = "fastapi-users:reset" - is_on_after_forgot_password_async = asyncio.iscoroutinefunction( - on_after_forgot_password - ) get_current_active_user = auth.get_current_active_user(user_db) @@ -74,10 +94,7 @@ def get_user_router( reset_password_token_lifetime_seconds, reset_password_token_secret, ) - if is_on_after_forgot_password_async: - await on_after_forgot_password(user, token) - else: - on_after_forgot_password(user, token) + await router.run_handlers(Events.ON_AFTER_FORGOT_PASSWORD, user, token) return None diff --git a/tests/test_fastapi_users.py b/tests/test_fastapi_users.py index 3716b85f..a4476050 100644 --- a/tests/test_fastapi_users.py +++ b/tests/test_fastapi_users.py @@ -22,9 +22,12 @@ def test_app_client(request, mock_user_db, mock_authentication) -> TestClient: class User(BaseUser): pass - fastapi_users = FastAPIUsers( - mock_user_db, mock_authentication, User, request.param, SECRET - ) + fastapi_users = FastAPIUsers(mock_user_db, mock_authentication, User, SECRET) + + @fastapi_users.on_after_forgot_password() + def on_after_forgot_password(): + return request.param() + app = FastAPI() app.include_router(fastapi_users.router) diff --git a/tests/test_router.py b/tests/test_router.py index 6b0a3a45..a83f0720 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -8,7 +8,7 @@ from starlette import status from starlette.testclient import TestClient from fastapi_users.models import BaseUser, BaseUserDB -from fastapi_users.router import get_user_router +from fastapi_users.router import Events, get_user_router from fastapi_users.utils import JWT_ALGORITHM, generate_jwt SECRET = "SECRET" @@ -47,12 +47,11 @@ def test_app_client( pass userRouter = get_user_router( - mock_user_db, - User, - mock_authentication, - on_after_forgot_password, - SECRET, - LIFETIME, + mock_user_db, User, mock_authentication, SECRET, LIFETIME + ) + + userRouter.add_event_handler( + Events.ON_AFTER_FORGOT_PASSWORD, on_after_forgot_password ) app = FastAPI() From 3506df31e7c70464f63dd6c6045207a220f869b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Fri, 25 Oct 2019 08:28:29 +0200 Subject: [PATCH 2/2] Add ON_AFTER_REGISTER event handler --- docs/configuration/router.md | 20 +++++++++-- docs/src/full_sqlalchemy.py | 7 +++- fastapi_users/fastapi_users.py | 4 +++ fastapi_users/router.py | 6 +++- tests/test_fastapi_users.py | 29 +++++++++++---- tests/test_router.py | 65 ++++++++++++++++++---------------- 6 files changed, 89 insertions(+), 42 deletions(-) diff --git a/docs/configuration/router.md b/docs/configuration/router.md index 465c2ff2..a139eb90 100644 --- a/docs/configuration/router.md +++ b/docs/configuration/router.md @@ -34,6 +34,22 @@ app.include_router(fastapi_users.router, prefix="/users", tags=["users"]) In order to be as unopinionated as possible, we expose decorators that allow you to plug your own logic after some actions. You can have several handlers per event. +### After register + +This event handler is called after a successful registration. It is called with **one argument**: the **user** that has just registered. + +Typically, you'll want to **send a welcome e-mail** or add it to your marketing analytics pipeline. + +You can define it as an `async` or standard method. + +Example: + +```py +@fastapi_users.on_after_register() +def on_after_register(user: User): + print(f"User {user.id} has registered.") +``` + ### After forgot password This event handler is called after a successful forgot password request. It is called with **two arguments**: the **user** which has requested to reset their password and a ready-to-use **JWT token** that will be accepted by the reset password route. @@ -46,8 +62,8 @@ Example: ```py @fastapi_users.on_after_forgot_password() -def on_after_forgot_password(user, token): - print(f'User {user.id} has forgot their password. Reset token: {token}') +def on_after_forgot_password(user: User, token: str): + print(f"User {user.id} has forgot their password. Reset token: {token}") ``` ## Next steps diff --git a/docs/src/full_sqlalchemy.py b/docs/src/full_sqlalchemy.py index 14fac916..1515ed1b 100644 --- a/docs/src/full_sqlalchemy.py +++ b/docs/src/full_sqlalchemy.py @@ -40,8 +40,13 @@ fastapi_users = FastAPIUsers(user_db, auth, User, 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, token): +def on_after_forgot_password(user: User, token: str): print(f"User {user.id} has forgot their password. Reset token: {token}") diff --git a/fastapi_users/fastapi_users.py b/fastapi_users/fastapi_users.py index 5a11e072..34a94f3e 100644 --- a/fastapi_users/fastapi_users.py +++ b/fastapi_users/fastapi_users.py @@ -52,6 +52,10 @@ class FastAPIUsers: get_current_superuser = self.auth.get_current_superuser(self.db) self.get_current_superuser = get_current_superuser # type: ignore + def on_after_register(self) -> Callable: + """Add an event handler on successful registration.""" + return self._on_event(Events.ON_AFTER_REGISTER) + def on_after_forgot_password(self) -> Callable: """Add an event handler on successful forgot password request.""" return self._on_event(Events.ON_AFTER_FORGOT_PASSWORD) diff --git a/fastapi_users/router.py b/fastapi_users/router.py index 6175ede7..db6ec268 100644 --- a/fastapi_users/router.py +++ b/fastapi_users/router.py @@ -18,7 +18,8 @@ from fastapi_users.utils import JWT_ALGORITHM, generate_jwt class Events(Enum): - ON_AFTER_FORGOT_PASSWORD = 1 + ON_AFTER_REGISTER = 1 + ON_AFTER_FORGOT_PASSWORD = 2 class UserRouter(APIRouter): @@ -68,6 +69,9 @@ def get_user_router( **user.create_update_dict(), hashed_password=hashed_password ) created_user = await user_db.create(db_user) + + await router.run_handlers(Events.ON_AFTER_REGISTER, created_user) + return created_user @router.post("/login") diff --git a/tests/test_fastapi_users.py b/tests/test_fastapi_users.py index a4476050..1cfac3ee 100644 --- a/tests/test_fastapi_users.py +++ b/tests/test_fastapi_users.py @@ -5,29 +5,37 @@ from starlette.testclient import TestClient from fastapi_users import FastAPIUsers from fastapi_users.models import BaseUser, BaseUserDB - -SECRET = "SECRET" +from fastapi_users.router import Events -def sync_on_after_forgot_password(): +def sync_event_handler(): return None -async def async_on_after_forgot_password(): +async def async_event_handler(): return None -@pytest.fixture(params=[sync_on_after_forgot_password, async_on_after_forgot_password]) -def test_app_client(request, mock_user_db, mock_authentication) -> TestClient: +@pytest.fixture(params=[sync_event_handler, async_event_handler]) +def fastapi_users(request, mock_user_db, mock_authentication) -> FastAPIUsers: class User(BaseUser): pass - fastapi_users = FastAPIUsers(mock_user_db, mock_authentication, User, SECRET) + fastapi_users = FastAPIUsers(mock_user_db, mock_authentication, User, "SECRET") + + @fastapi_users.on_after_register() + def on_after_register(): + return request.param() @fastapi_users.on_after_forgot_password() def on_after_forgot_password(): return request.param() + return fastapi_users + + +@pytest.fixture() +def test_app_client(fastapi_users) -> TestClient: app = FastAPI() app.include_router(fastapi_users.router) @@ -46,6 +54,13 @@ def test_app_client(request, mock_user_db, mock_authentication) -> TestClient: return TestClient(app) +class TestFastAPIUsers: + def test_event_handlers(self, fastapi_users): + event_handlers = fastapi_users.router.event_handlers + assert len(event_handlers[Events.ON_AFTER_REGISTER]) == 1 + assert len(event_handlers[Events.ON_AFTER_FORGOT_PASSWORD]) == 1 + + class TestRouter: def test_routes_exist(self, test_app_client: TestClient): response = test_app_client.post("/register") diff --git a/tests/test_router.py b/tests/test_router.py index a83f0720..4edf82c6 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -26,23 +26,21 @@ def forgot_password_token(): return _forgot_password_token -def on_after_forgot_password_sync(): +def event_handler_sync(): return MagicMock(return_value=None) -def on_after_forgot_password_async(): +def event_handler_async(): return asynctest.CoroutineMock(return_value=None) -@pytest.fixture(params=[on_after_forgot_password_sync, on_after_forgot_password_async]) -def on_after_forgot_password(request): +@pytest.fixture(params=[event_handler_sync, event_handler_async]) +def event_handler(request): return request.param() @pytest.fixture() -def test_app_client( - mock_user_db, mock_authentication, on_after_forgot_password -) -> TestClient: +def test_app_client(mock_user_db, mock_authentication, event_handler) -> TestClient: class User(BaseUser): pass @@ -50,9 +48,8 @@ def test_app_client( mock_user_db, User, mock_authentication, SECRET, LIFETIME ) - userRouter.add_event_handler( - Events.ON_AFTER_FORGOT_PASSWORD, on_after_forgot_password - ) + userRouter.add_event_handler(Events.ON_AFTER_REGISTER, event_handler) + userRouter.add_event_handler(Events.ON_AFTER_FORGOT_PASSWORD, event_handler) app = FastAPI() app.include_router(userRouter) @@ -61,36 +58,44 @@ def test_app_client( class TestRegister: - def test_empty_body(self, test_app_client: TestClient): + def test_empty_body(self, test_app_client: TestClient, event_handler): response = test_app_client.post("/register", json={}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert event_handler.called is False - def test_missing_password(self, test_app_client: TestClient): + def test_missing_password(self, test_app_client: TestClient, event_handler): json = {"email": "king.arthur@camelot.bt"} response = test_app_client.post("/register", json=json) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert event_handler.called is False - def test_wrong_email(self, test_app_client: TestClient): + def test_wrong_email(self, test_app_client: TestClient, event_handler): json = {"email": "king.arthur", "password": "guinevere"} response = test_app_client.post("/register", json=json) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert event_handler.called is False - def test_existing_user(self, test_app_client: TestClient): + def test_existing_user(self, test_app_client: TestClient, event_handler): json = {"email": "king.arthur@camelot.bt", "password": "guinevere"} response = test_app_client.post("/register", json=json) assert response.status_code == status.HTTP_400_BAD_REQUEST + assert event_handler.called is False - def test_valid_body(self, test_app_client: TestClient): + def test_valid_body(self, test_app_client: TestClient, event_handler): json = {"email": "lancelot@camelot.bt", "password": "guinevere"} response = test_app_client.post("/register", json=json) assert response.status_code == status.HTTP_201_CREATED + assert event_handler.called is True response_json = response.json() assert "hashed_password" not in response_json assert "password" not in response_json assert response_json["id"] is not None - def test_valid_body_is_superuser(self, test_app_client: TestClient): + actual_user = event_handler.call_args[0][0] + assert actual_user.id == response_json["id"] + + def test_valid_body_is_superuser(self, test_app_client: TestClient, event_handler): json = { "email": "lancelot@camelot.bt", "password": "guinevere", @@ -98,11 +103,12 @@ class TestRegister: } response = test_app_client.post("/register", json=json) assert response.status_code == status.HTTP_201_CREATED + assert event_handler.called is True response_json = response.json() assert response_json["is_superuser"] is False - def test_valid_body_is_active(self, test_app_client: TestClient): + def test_valid_body_is_active(self, test_app_client: TestClient, event_handler): json = { "email": "lancelot@camelot.bt", "password": "guinevere", @@ -110,6 +116,7 @@ class TestRegister: } response = test_app_client.post("/register", json=json) assert response.status_code == status.HTTP_201_CREATED + assert event_handler.called is True response_json = response.json() assert response_json["is_active"] is True @@ -153,36 +160,32 @@ class TestLogin: class TestForgotPassword: - def test_empty_body(self, test_app_client: TestClient, on_after_forgot_password): + def test_empty_body(self, test_app_client: TestClient, event_handler): response = test_app_client.post("/forgot-password", json={}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert on_after_forgot_password.called is False + assert event_handler.called is False - def test_not_existing_user( - self, test_app_client: TestClient, on_after_forgot_password - ): + def test_not_existing_user(self, test_app_client: TestClient, event_handler): json = {"email": "lancelot@camelot.bt"} response = test_app_client.post("/forgot-password", json=json) assert response.status_code == status.HTTP_202_ACCEPTED - assert on_after_forgot_password.called is False + assert event_handler.called is False - def test_inactive_user(self, test_app_client: TestClient, on_after_forgot_password): + def test_inactive_user(self, test_app_client: TestClient, event_handler): json = {"email": "percival@camelot.bt"} response = test_app_client.post("/forgot-password", json=json) assert response.status_code == status.HTTP_202_ACCEPTED - assert on_after_forgot_password.called is False + assert event_handler.called is False - def test_existing_user( - self, test_app_client: TestClient, on_after_forgot_password, user - ): + def test_existing_user(self, test_app_client: TestClient, event_handler, user): json = {"email": "king.arthur@camelot.bt"} response = test_app_client.post("/forgot-password", json=json) assert response.status_code == status.HTTP_202_ACCEPTED - assert on_after_forgot_password.called is True + assert event_handler.called is True - actual_user = on_after_forgot_password.call_args[0][0] + actual_user = event_handler.call_args[0][0] assert actual_user.id == user.id - actual_token = on_after_forgot_password.call_args[0][1] + actual_token = event_handler.call_args[0][1] decoded_token = jwt.decode( actual_token, SECRET,