Implement password validation mechanism (#632)

* Implement password validation mechanism

* Add invalid password reason

* Always pass user in password validator

* Add password validation documentation
This commit is contained in:
François Voron
2021-05-17 08:58:23 +02:00
committed by GitHub
parent 5b76d5d90a
commit 5267e605f4
18 changed files with 320 additions and 34 deletions

View File

@ -4,3 +4,4 @@ __version__ = "6.0.0"
from fastapi_users import models # noqa: F401
from fastapi_users.fastapi_users import FastAPIUsers # noqa: F401
from fastapi_users.user import InvalidPasswordException # noqa: F401

View File

@ -15,6 +15,7 @@ from fastapi_users.router import (
from fastapi_users.user import (
CreateUserProtocol,
GetUserProtocol,
ValidatePasswordProtocol,
VerifyUserProtocol,
get_create_user,
get_get_user,
@ -39,6 +40,8 @@ class FastAPIUsers:
:param user_create_model: Pydantic model for creating a user.
:param user_update_model: Pydantic model for updating a user.
:param user_db_model: Pydantic model of a DB representation of a user.
:param validate_password: Optional function to validate the password
at user registration, user update or password reset.
:attribute create_user: Helper function to create a user programmatically.
:attribute current_user: Dependency callable getter to inject authenticated user
@ -56,6 +59,7 @@ class FastAPIUsers:
create_user: CreateUserProtocol
verify_user: VerifyUserProtocol
get_user: GetUserProtocol
validate_password: Optional[ValidatePasswordProtocol]
_user_model: Type[models.BaseUser]
_user_create_model: Type[models.BaseUserCreate]
_user_update_model: Type[models.BaseUserUpdate]
@ -69,6 +73,7 @@ class FastAPIUsers:
user_create_model: Type[models.BaseUserCreate],
user_update_model: Type[models.BaseUserUpdate],
user_db_model: Type[models.BaseUserDB],
validate_password: Optional[ValidatePasswordProtocol] = None,
):
self.db = db
self.authenticator = Authenticator(auth_backends, db)
@ -83,6 +88,8 @@ class FastAPIUsers:
self.verify_user = get_verify_user(db)
self.get_user = get_get_user(db)
self.validate_password = validate_password
self.current_user = self.authenticator.current_user
self.get_current_user = self.authenticator.get_current_user
self.get_current_active_user = self.authenticator.get_current_active_user
@ -120,6 +127,7 @@ class FastAPIUsers:
self._user_model,
self._user_create_model,
after_register,
self.validate_password,
)
def get_verify_router(
@ -176,6 +184,7 @@ class FastAPIUsers:
reset_password_token_lifetime_seconds,
after_forgot_password,
after_reset_password,
self.validate_password,
)
def get_auth_router(
@ -185,6 +194,8 @@ class FastAPIUsers:
Return an auth router for a given authentication backend.
:param backend: The authentication backend instance.
:param requires_verification: Whether the authentication
require the user to be verified or not.
"""
return get_auth_router(
backend,
@ -232,6 +243,8 @@ class FastAPIUsers:
:param after_update: Optional function called
after a successful user update.
:param requires_verification: Whether the endpoints
require the users to be verified or not.
"""
return get_users_router(
self.db,
@ -241,4 +254,5 @@ class FastAPIUsers:
self.authenticator,
after_update,
requires_verification,
self.validate_password,
)

View File

@ -3,14 +3,17 @@ from typing import Callable
class ErrorCode:
REGISTER_INVALID_PASSWORD = "REGISTER_INVALID_PASSWORD"
REGISTER_USER_ALREADY_EXISTS = "REGISTER_USER_ALREADY_EXISTS"
LOGIN_BAD_CREDENTIALS = "LOGIN_BAD_CREDENTIALS"
LOGIN_USER_NOT_VERIFIED = "LOGIN_USER_NOT_VERIFIED"
RESET_PASSWORD_BAD_TOKEN = "RESET_PASSWORD_BAD_TOKEN"
RESET_PASSWORD_INVALID_PASSWORD = "RESET_PASSWORD_INVALID_PASSWORD"
VERIFY_USER_BAD_TOKEN = "VERIFY_USER_BAD_TOKEN"
VERIFY_USER_ALREADY_VERIFIED = "VERIFY_USER_ALREADY_VERIFIED"
VERIFY_USER_TOKEN_EXPIRED = "VERIFY_USER_TOKEN_EXPIRED"
UPDATE_USER_EMAIL_ALREADY_EXISTS = "UPDATE_USER_EMAIL_ALREADY_EXISTS"
UPDATE_USER_INVALID_PASSWORD = "UPDATE_USER_INVALID_PASSWORD"
async def run_handler(handler: Callable, *args, **kwargs):

View File

@ -1,10 +1,15 @@
from typing import Callable, Optional, Type
from typing import Callable, Optional, Type, cast
from fastapi import APIRouter, HTTPException, Request, status
from fastapi_users import models
from fastapi_users.router.common import ErrorCode, run_handler
from fastapi_users.user import CreateUserProtocol, UserAlreadyExists
from fastapi_users.user import (
CreateUserProtocol,
InvalidPasswordException,
UserAlreadyExists,
ValidatePasswordProtocol,
)
def get_register_router(
@ -12,6 +17,7 @@ def get_register_router(
user_model: Type[models.BaseUser],
user_create_model: Type[models.BaseUserCreate],
after_register: Optional[Callable[[models.UD, Request], None]] = None,
validate_password: Optional[ValidatePasswordProtocol] = None,
) -> APIRouter:
"""Generate a router with the register route."""
router = APIRouter()
@ -20,6 +26,19 @@ def get_register_router(
"/register", response_model=user_model, status_code=status.HTTP_201_CREATED
)
async def register(request: Request, user: user_create_model): # type: ignore
user = cast(models.BaseUserCreate, user) # Prevent mypy complain
if validate_password:
try:
await validate_password(user.password, user)
except InvalidPasswordException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": ErrorCode.REGISTER_INVALID_PASSWORD,
"reason": e.reason,
},
)
try:
created_user = await create_user(user, safe=True)
except UserAlreadyExists:

View File

@ -8,6 +8,7 @@ from fastapi_users import models
from fastapi_users.db import BaseUserDatabase
from fastapi_users.password import get_password_hash
from fastapi_users.router.common import ErrorCode, run_handler
from fastapi_users.user import InvalidPasswordException, ValidatePasswordProtocol
from fastapi_users.utils import JWT_ALGORITHM, generate_jwt
RESET_PASSWORD_TOKEN_AUDIENCE = "fastapi-users:reset"
@ -19,6 +20,7 @@ def get_reset_password_router(
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,
validate_password: Optional[ValidatePasswordProtocol] = None,
) -> APIRouter:
"""Generate a router with the reset password routes."""
router = APIRouter()
@ -74,6 +76,18 @@ def get_reset_password_router(
detail=ErrorCode.RESET_PASSWORD_BAD_TOKEN,
)
if validate_password:
try:
await 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_db.update(user)
if after_reset_password:

View File

@ -8,6 +8,7 @@ from fastapi_users.authentication import Authenticator
from fastapi_users.db import BaseUserDatabase
from fastapi_users.password import get_password_hash
from fastapi_users.router.common import ErrorCode, run_handler
from fastapi_users.user import InvalidPasswordException, ValidatePasswordProtocol
def get_users_router(
@ -18,6 +19,7 @@ def get_users_router(
authenticator: Authenticator,
after_update: Optional[Callable[[models.UD, Dict[str, Any], Request], None]] = None,
requires_verification: bool = False,
validate_password: Optional[ValidatePasswordProtocol] = None,
) -> APIRouter:
"""Generate a router with the authentication routes."""
router = APIRouter()
@ -54,7 +56,10 @@ def get_users_router(
):
for field in update_dict:
if field == "password":
hashed_password = get_password_hash(update_dict[field])
password = update_dict[field]
if validate_password:
await validate_password(password, user)
hashed_password = get_password_hash(password)
user.hashed_password = hashed_password
else:
setattr(user, field, update_dict[field])
@ -84,9 +89,17 @@ def get_users_router(
updated_user,
) # Prevent mypy complain
updated_user_data = updated_user.create_update_dict()
updated_user = await _update_user(user, updated_user_data, request)
return updated_user
try:
return await _update_user(user, updated_user_data, request)
except InvalidPasswordException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": ErrorCode.UPDATE_USER_INVALID_PASSWORD,
"reason": e.reason,
},
)
@router.get(
"/{id:uuid}",
@ -110,7 +123,17 @@ def get_users_router(
) # Prevent mypy complain
user = await _get_or_404(id)
updated_user_data = updated_user.create_update_dict_superuser()
return await _update_user(user, updated_user_data, request)
try:
return await _update_user(user, updated_user_data, request)
except InvalidPasswordException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": ErrorCode.UPDATE_USER_INVALID_PASSWORD,
"reason": e.reason,
},
)
@router.delete(
"/{id:uuid}",

View File

@ -1,4 +1,4 @@
from typing import Awaitable, Type
from typing import Any, Awaitable, Union, Type
try:
from typing import Protocol
@ -12,18 +12,34 @@ from fastapi_users.db import BaseUserDatabase
from fastapi_users.password import get_password_hash
class UserAlreadyExists(Exception):
class FastAPIUsersException(Exception):
pass
class UserNotExists(Exception):
class UserAlreadyExists(FastAPIUsersException):
pass
class UserAlreadyVerified(Exception):
class UserNotExists(FastAPIUsersException):
pass
class UserAlreadyVerified(FastAPIUsersException):
pass
class InvalidPasswordException(FastAPIUsersException):
def __init__(self, reason: Any) -> None:
self.reason = reason
class ValidatePasswordProtocol(Protocol): # pragma: no cover
def __call__(
self, password: str, user: Union[models.BaseUserCreate, models.BaseUserDB]
) -> Awaitable[None]:
pass
class CreateUserProtocol(Protocol): # pragma: no cover
def __call__(
self,