Move forgot/reset password logic to manager

This commit is contained in:
François Voron
2021-09-15 09:13:11 +02:00
parent 38d4aed817
commit 663b7405a7
7 changed files with 288 additions and 311 deletions

View File

@@ -100,32 +100,9 @@ class FastAPIUsers(Generic[models.U, models.UC, models.UU, models.UD]):
after_verification,
)
def get_reset_password_router(
self,
reset_password_token_secret: SecretType,
reset_password_token_lifetime_seconds: int = 3600,
after_forgot_password: Optional[
Callable[[models.UD, str, Request], None]
] = None,
after_reset_password: Optional[Callable[[models.UD, 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.
:param after_reset_password: Optional function called after a successful
password reset.
"""
return get_reset_password_router(
self.get_user_manager,
reset_password_token_secret,
reset_password_token_lifetime_seconds,
after_forgot_password,
after_reset_password,
)
def get_reset_password_router(self) -> APIRouter:
"""Return a reset password process router."""
return get_reset_password_router(self.get_user_manager)
def get_auth_router(
self, backend: BaseAuthentication, requires_verification: bool = False

View File

@@ -1,13 +1,17 @@
from typing import Any, Callable, Dict, Generic, Optional, Type, Union
import jwt
from fastapi import Request
from fastapi.security import OAuth2PasswordRequestForm
from pydantic.types import UUID4
from pydantic import UUID4
from fastapi_users import models, password
from fastapi_users.db import BaseUserDatabase
from fastapi_users.jwt import SecretType, decode_jwt, generate_jwt
from fastapi_users.password import get_password_hash
RESET_PASSWORD_TOKEN_AUDIENCE = "fastapi-users:reset"
class FastAPIUsersException(Exception):
pass
@@ -21,10 +25,18 @@ class UserNotExists(FastAPIUsersException):
pass
class UserInactive(FastAPIUsersException):
pass
class UserAlreadyVerified(FastAPIUsersException):
pass
class InvalidResetPasswordToken(FastAPIUsersException):
pass
class InvalidPasswordException(FastAPIUsersException):
def __init__(self, reason: Any) -> None:
self.reason = reason
@@ -35,6 +47,10 @@ class BaseUserManager(Generic[models.UC, models.UD]):
user_db_model: Type[models.UD]
user_db: BaseUserDatabase[models.UD]
reset_password_token_secret: SecretType
reset_password_token_lifetime_seconds: int = 3600
reset_password_token_audience: str = RESET_PASSWORD_TOKEN_AUDIENCE
def __init__(
self,
user_db_model: Type[models.UD],
@@ -88,6 +104,53 @@ class BaseUserManager(Generic[models.UC, models.UD]):
return created_user
async def forgot_password(
self, user: models.UD, request: Optional[Request] = None
) -> None:
if not user.is_active:
raise UserInactive()
token_data = {"user_id": str(user.id), "aud": RESET_PASSWORD_TOKEN_AUDIENCE}
token = generate_jwt(
token_data,
self.reset_password_token_secret,
self.reset_password_token_lifetime_seconds,
)
await self.on_after_forgot_password(user, token, request)
async def reset_password(
self, token: str, password: str, request: Optional[Request] = None
) -> models.UD:
try:
data = decode_jwt(
token,
self.reset_password_token_secret,
[self.reset_password_token_audience],
)
except jwt.PyJWTError:
raise InvalidResetPasswordToken()
try:
user_id = data["user_id"]
except KeyError:
raise InvalidResetPasswordToken()
try:
user_uuid = UUID4(user_id)
except ValueError:
raise InvalidResetPasswordToken()
user = await self.get(user_uuid)
if not user.is_active:
raise UserInactive()
updated_user = await self._update(user, {"password": password})
await self.on_after_reset_password(user, request)
return updated_user
async def verify(self, user: models.UD) -> models.UD:
if user.is_verified:
raise UserAlreadyVerified()
@@ -117,6 +180,16 @@ class BaseUserManager(Generic[models.UC, models.UD]):
) -> None:
return # pragma: no cover
async def on_after_forgot_password(
self, user: models.UD, token: str, request: Optional[Request] = None
) -> None:
return # pragma: no cover
async def on_after_reset_password(
self, user: models.UD, request: Optional[Request] = None
) -> None:
return # pragma: no cover
async def authenticate(
self, credentials: OAuth2PasswordRequestForm
) -> Optional[models.UD]:

View File

@@ -1,29 +1,20 @@
from typing import Callable, Optional
import jwt
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
from pydantic import UUID4, EmailStr
from pydantic import EmailStr
from fastapi_users import models
from fastapi_users.jwt import SecretType, decode_jwt, generate_jwt
from fastapi_users.manager import (
BaseUserManager,
InvalidPasswordException,
InvalidResetPasswordToken,
UserInactive,
UserManagerDependency,
UserNotExists,
)
from fastapi_users.password import get_password_hash
from fastapi_users.router.common import ErrorCode, run_handler
RESET_PASSWORD_TOKEN_AUDIENCE = "fastapi-users:reset"
from fastapi_users.router.common import ErrorCode
def get_reset_password_router(
get_user_manager: UserManagerDependency[models.UC, models.UD],
reset_password_token_secret: SecretType,
reset_password_token_lifetime_seconds: int = 3600,
after_forgot_password: Optional[Callable[[models.UD, str, Request], None]] = None,
after_reset_password: Optional[Callable[[models.UD, Request], None]] = None,
get_user_manager: UserManagerDependency[models.UC, models.UD]
) -> APIRouter:
"""Generate a router with the reset password routes."""
router = APIRouter()
@@ -39,15 +30,10 @@ def get_reset_password_router(
except UserNotExists:
return None
if user.is_active:
token_data = {"user_id": str(user.id), "aud": RESET_PASSWORD_TOKEN_AUDIENCE}
token = generate_jwt(
token_data,
reset_password_token_secret,
reset_password_token_lifetime_seconds,
)
if after_forgot_password:
await run_handler(after_forgot_password, user, token, request)
try:
await user_manager.forgot_password(user, request)
except UserInactive:
pass
return None
@@ -59,57 +45,19 @@ def get_reset_password_router(
user_manager: BaseUserManager[models.UC, models.UD] = Depends(get_user_manager),
):
try:
data = decode_jwt(
token, reset_password_token_secret, [RESET_PASSWORD_TOKEN_AUDIENCE]
)
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,
)
try:
user = await user_manager.get(user_uiid)
except UserNotExists:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
try:
await user_manager.validate_password(password, user)
except InvalidPasswordException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": ErrorCode.RESET_PASSWORD_INVALID_PASSWORD,
"reason": e.reason,
},
)
user.hashed_password = get_password_hash(password)
await user_manager.user_db.update(user)
if after_reset_password:
await run_handler(after_reset_password, user, request)
except jwt.PyJWTError:
await user_manager.reset_password(token, password, request)
except (InvalidResetPasswordToken, UserNotExists, UserInactive):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
except InvalidPasswordException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": ErrorCode.RESET_PASSWORD_INVALID_PASSWORD,
"reason": e.reason,
},
)
return router