on_after_login hook (#1092)

* on_after_login minimal impl.

Questions: is the spot logical for after method? Is after the internal login call.
Would before_login be needed? Maybe not, as auth is the way to do pre-login things.

Added fastapi request as a param just in case, as other callbacks had it too.

Docs addition is missing.

* tried to complete the implementation, but the test with user_manager.on_after_login.called fails though

* move on_after_login tests to right place, to TestLogin. These ones pass.

TODO: check TestCallback

* on_after_login tests to TestCallback too, for oauth. Apparently test_redirect_url_router fires the callback too, I guess that's correct, am not using oauth myself.

* fix formatting with make format

* docs for on_after_login

Co-authored-by: Toni Alatalo <toni.alatalo@gmail.com>
This commit is contained in:
Toni Alatalo
2022-10-18 09:02:01 +03:00
committed by GitHub
parent a665cd5ed7
commit 7ad5f8073d
7 changed files with 74 additions and 3 deletions

View File

@@ -164,6 +164,33 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
print(f"User {user.id} has been updated with {update_dict}.")
```
#### `on_after_login`
Perform logic after a successful user login.
It may be useful for custom logic or processes triggered by new logins, for example a daily login reward or for analytics.
**Arguments**
* `user` (`User`): the updated user.
* `request` (`Optional[Request]`): optional FastAPI request object that triggered the operation. Defaults to None.
**Example**
```py
from fastapi_users import BaseUserManager, UUIDIDMixin
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
# ...
async def on_after_login(
self,
user: User,
request: Optional[Request] = None,
):
print(f"User {user.id} logged in.")
```
#### `on_after_request_verify`
Perform logic after successful verification request.

View File

@@ -571,6 +571,20 @@ class BaseUserManager(Generic[models.UP, models.ID]):
"""
return # pragma: no cover
async def on_after_login(
self, user: models.UP, request: Optional[Request] = None
) -> None:
"""
Perform logic after user login.
*You should overload this method to add your own logic.*
:param user: The user that is logging in
:param request: Optional FastAPI request that
triggered the operation, defaults to None.
"""
return # pragma: no cover
async def on_before_delete(
self, user: models.UP, request: Optional[Request] = None
) -> None:

View File

@@ -1,6 +1,6 @@
from typing import Tuple
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from fastapi_users import models
@@ -49,6 +49,7 @@ def get_auth_router(
responses=login_responses,
)
async def login(
request: Request,
response: Response,
credentials: OAuth2PasswordRequestForm = Depends(),
user_manager: BaseUserManager[models.UP, models.ID] = Depends(get_user_manager),
@@ -66,7 +67,9 @@ def get_auth_router(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.LOGIN_USER_NOT_VERIFIED,
)
return await backend.login(strategy, user, response)
login_return = await backend.login(strategy, user, response)
await user_manager.on_after_login(user, request)
return login_return
logout_responses: OpenAPIResponseType = {
**{

View File

@@ -140,7 +140,9 @@ def get_oauth_router(
)
# Authenticate
return await backend.login(strategy, user, response)
login_return = await backend.login(strategy, user, response)
await user_manager.on_after_login(user, request)
return login_return
return router

View File

@@ -122,6 +122,7 @@ class UserManagerMock(BaseTestUserManager[models.UP]):
on_after_update: MagicMock
on_before_delete: MagicMock
on_after_delete: MagicMock
on_after_login: MagicMock
_update: MagicMock
@@ -479,6 +480,7 @@ def make_user_manager(mocker: MockerFixture):
mocker.spy(user_manager, "on_after_update")
mocker.spy(user_manager, "on_before_delete")
mocker.spy(user_manager, "on_after_delete")
mocker.spy(user_manager, "on_after_login")
mocker.spy(user_manager, "_update")
return user_manager

View File

@@ -61,35 +61,42 @@ class TestLogin:
self,
path,
test_app_client: Tuple[httpx.AsyncClient, bool],
user_manager,
):
client, _ = test_app_client
response = await client.post(path, data={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert user_manager.on_after_login.called is False
async def test_missing_username(
self,
path,
test_app_client: Tuple[httpx.AsyncClient, bool],
user_manager,
):
client, _ = test_app_client
data = {"password": "guinevere"}
response = await client.post(path, data=data)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert user_manager.on_after_login.called is False
async def test_missing_password(
self,
path,
test_app_client: Tuple[httpx.AsyncClient, bool],
user_manager,
):
client, _ = test_app_client
data = {"username": "king.arthur@camelot.bt"}
response = await client.post(path, data=data)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert user_manager.on_after_login.called is False
async def test_not_existing_user(
self,
path,
test_app_client: Tuple[httpx.AsyncClient, bool],
user_manager,
):
client, _ = test_app_client
data = {"username": "lancelot@camelot.bt", "password": "guinevere"}
@@ -97,11 +104,13 @@ class TestLogin:
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
assert user_manager.on_after_login.called is False
async def test_wrong_password(
self,
path,
test_app_client: Tuple[httpx.AsyncClient, bool],
user_manager,
):
client, _ = test_app_client
data = {"username": "king.arthur@camelot.bt", "password": "percival"}
@@ -109,6 +118,7 @@ class TestLogin:
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
assert user_manager.on_after_login.called is False
@pytest.mark.parametrize(
"email", ["king.arthur@camelot.bt", "King.Arthur@camelot.bt"]
@@ -118,6 +128,7 @@ class TestLogin:
path,
email,
test_app_client: Tuple[httpx.AsyncClient, bool],
user_manager,
user: UserModel,
):
client, requires_verification = test_app_client
@@ -127,12 +138,14 @@ class TestLogin:
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_USER_NOT_VERIFIED
assert user_manager.on_after_login.called is False
else:
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"access_token": str(user.id),
"token_type": "bearer",
}
assert user_manager.on_after_login.called is True
@pytest.mark.parametrize("email", ["lake.lady@camelot.bt", "Lake.Lady@camelot.bt"])
async def test_valid_credentials_verified(
@@ -140,6 +153,7 @@ class TestLogin:
path,
email,
test_app_client: Tuple[httpx.AsyncClient, bool],
user_manager,
verified_user: UserModel,
):
client, _ = test_app_client
@@ -150,11 +164,13 @@ class TestLogin:
"access_token": str(verified_user.id),
"token_type": "bearer",
}
assert user_manager.on_after_login.called is True
async def test_inactive_user(
self,
path,
test_app_client: Tuple[httpx.AsyncClient, bool],
user_manager,
):
client, _ = test_app_client
data = {"username": "percival@camelot.bt", "password": "angharad"}
@@ -162,6 +178,7 @@ class TestLogin:
assert response.status_code == status.HTTP_400_BAD_REQUEST
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.LOGIN_BAD_CREDENTIALS
assert user_manager.on_after_login.called is False
@pytest.mark.router

View File

@@ -188,6 +188,8 @@ class TestCallback:
data = cast(Dict[str, Any], response.json())
assert data["detail"] == ErrorCode.OAUTH_USER_ALREADY_EXISTS
assert user_manager_oauth.on_after_login.called is False
async def test_active_user(
self,
async_method_mocker: AsyncMethodMocker,
@@ -216,6 +218,8 @@ class TestCallback:
data = cast(Dict[str, Any], response.json())
assert data["access_token"] == str(user_oauth.id)
assert user_manager_oauth.on_after_login.called is True
async def test_inactive_user(
self,
async_method_mocker: AsyncMethodMocker,
@@ -242,6 +246,7 @@ class TestCallback:
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert user_manager_oauth.on_after_login.called is False
async def test_redirect_url_router(
self,
@@ -276,6 +281,7 @@ class TestCallback:
data = cast(Dict[str, Any], response.json())
assert data["access_token"] == str(user_oauth.id)
assert user_manager_oauth.on_after_login.called is True
@pytest.mark.router