Refactor password hasher injection and usage

Centralizes password hasher initialization and injects it via FastAPI dependency in session, user, and sign-up token routers. Updates user CRUD and utility functions to accept the password hasher as an argument, improving testability and future extensibility. Removes legacy hasher initialization from session.security and ensures all password operations use the injected hasher instance.
This commit is contained in:
João Vitória Silva
2025-10-04 21:57:57 +01:00
parent 0f88b4a2c0
commit f45125b844
8 changed files with 95 additions and 36 deletions

View File

@@ -241,3 +241,20 @@ class PasswordHasher:
return True
except PasswordPolicyError:
return False
def get_password_hasher():
"""
Returns the password hasher instance.
This function provides access to the application's password hasher, which is used for securely hashing and verifying passwords.
Returns:
password_hasher: An instance of the password hasher used for password operations.
"""
return password_hasher
# Initialize the PasswordHasher with both Argon2 and Bcrypt support
# Argon2 listed first => new hashes use Argon2; bcrypt remains verifiable for legacy rows.
password_hasher = PasswordHasher(hasher=[Argon2Hasher(), BcryptHasher()])

View File

@@ -16,6 +16,7 @@ import session.utils as session_utils
import session.security as session_security
import session.crud as session_crud
import session.schema as session_schema
import session.password_hasher as session_password_hasher
import users.user.crud as users_crud
import users.user.utils as users_utils
@@ -36,6 +37,10 @@ async def login_for_access_token(
pending_mfa_store: Annotated[
session_schema.PendingMFALogin, Depends(session_schema.get_pending_mfa_store)
],
password_hasher: Annotated[
session_password_hasher.PasswordHasher,
Depends(session_password_hasher.get_password_hasher),
],
db: Annotated[
Session,
Depends(core_database.get_db),
@@ -55,6 +60,7 @@ async def login_for_access_token(
form_data: Form data containing username and password
client_type: The type of client making the request ("web" or "mobile")
pending_mfa_store: Store for pending MFA logins
password_hasher: The password hasher instance used for verifying passwords
db: Database session
Returns:
@@ -65,7 +71,9 @@ async def login_for_access_token(
Raises:
HTTPException: If authentication fails or the user is inactive
"""
user = session_utils.authenticate_user(form_data.username, form_data.password, db)
user = session_utils.authenticate_user(
form_data.username, form_data.password, password_hasher, db
)
# Check if the user is active
users_utils.check_user_is_active(user)

View File

@@ -13,13 +13,7 @@ from fastapi.security import (
from joserfc import jwt
from joserfc.jwk import OctKey
# Import password hashing libraries
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher
import session.constants as session_constants
import session.password_hasher as session_password_hasher
import core.logger as core_logger
@@ -44,12 +38,6 @@ cookie_refresh_token_scheme = APIKeyCookie(
auto_error=False,
)
# Initialize the PasswordHasher with both Argon2 and Bcrypt support
# Argon2 listed first => new hashes use Argon2; bcrypt remains verifiable for legacy rows.
password_hasher = session_password_hasher.PasswordHasher(
hasher=[Argon2Hasher(), BcryptHasher()]
)
def decode_token(token: Annotated[str, Depends(oauth2_scheme)]) -> dict:
try:

View File

@@ -19,6 +19,7 @@ import session.security as session_security
import session.constants as session_constants
import session.schema as session_schema
import session.crud as session_crud
import session.password_hasher as session_password_hasher
import users.user.crud as users_crud
import users.user.schema as users_schema
@@ -108,7 +109,10 @@ def edit_session_object(
def authenticate_user(
username: str, password: str, db: Session
username: str,
password: str,
password_hasher: session_password_hasher.PasswordHasher,
db: Session,
) -> users_schema.UserRead:
"""
Authenticates a user by verifying the provided username and password.
@@ -116,6 +120,7 @@ def authenticate_user(
Args:
username (str): The username of the user attempting to authenticate.
password (str): The password provided by the user.
password_hasher (PasswordHasher): The password hasher instance used for verification.
db (Session): The database session used to query user data.
Returns:
users_schema.UserRead: The authenticated user object.
@@ -134,8 +139,8 @@ def authenticate_user(
)
# Verify password and get updated hash if applicable
is_password_valid, updated_hash = (
session_security.password_hasher.verify_and_update(password, user.password)
is_password_valid, updated_hash = password_hasher.verify_and_update(
password, user.password
)
if not is_password_valid:
raise HTTPException(
@@ -146,7 +151,9 @@ def authenticate_user(
# Update user hash if applicable
if updated_hash:
users_crud.edit_user_password(user.id, updated_hash, db, is_hashed=True)
users_crud.edit_user_password(
user.id, updated_hash, password_hasher, db, is_hashed=True
)
# Return the user if the password is correct
return user

View File

@@ -22,6 +22,8 @@ import health_targets.crud as health_targets_crud
import sign_up_tokens.utils as sign_up_tokens_utils
import sign_up_tokens.schema as sign_up_tokens_schema
import session.password_hasher as session_password_hasher
import server_settings.utils as server_settings_utils
import core.database as core_database
@@ -40,6 +42,10 @@ async def signup(
core_apprise.AppriseService,
Depends(core_apprise.get_email_service),
],
password_hasher: Annotated[
session_password_hasher.PasswordHasher,
Depends(session_password_hasher.get_password_hasher),
],
db: Annotated[
Session,
Depends(core_database.get_db),
@@ -55,6 +61,7 @@ async def signup(
verification and admin approval emails.
- websocket_manager (websocket_schema.WebSocketManager): Injected manager used to send
real-time notifications (e.g., admin approval requests).
- password_hasher (session_password_hasher.PasswordHasher): Injected password hasher used to hash user passwords.
- db (Session): Database session/connection used to create the user and related records.
Behavior and side effects
@@ -107,7 +114,9 @@ async def signup(
)
# Create the user in the database
created_user = users_crud.create_signup_user(user, server_settings, db)
created_user = users_crud.create_signup_user(
user, server_settings, password_hasher, db
)
# Create the user integrations in the database
user_integrations_crud.create_user_integrations(created_user.id, db)

View File

@@ -26,7 +26,7 @@ def authenticate_user(username: str, db: Session) -> users_models.User | None:
.filter(users_models.User.username == username.lower())
.first()
)
return user
except Exception as err:
# Log the exception
@@ -280,13 +280,19 @@ def get_users_admin(db: Session):
) from err
def create_user(user: users_schema.UserCreate, db: Session):
def create_user(
user: users_schema.UserCreate,
password_hasher: session_password_hasher.PasswordHasher,
db: Session,
):
try:
user.username = user.username.lower()
user.email = user.email.lower()
# Hash the password
hashed_password = users_utils.check_password_and_hash(user.password, 8)
hashed_password = users_utils.check_password_and_hash(
user.password, password_hasher, 8
)
# Create a new user
db_user = users_models.User(
@@ -392,9 +398,7 @@ def edit_user(user_id: int, user: users_schema.UserRead, db: Session):
) from err
def approve_user(
user_id: int, db: Session
):
def approve_user(user_id: int, db: Session):
"""
Approve a user by ID.
@@ -551,7 +555,13 @@ def verify_user_email(
) from err
def edit_user_password(user_id: int, password: str, db: Session, is_hashed: bool = False):
def edit_user_password(
user_id: int,
password: str,
password_hasher: session_password_hasher.PasswordHasher,
db: Session,
is_hashed: bool = False,
):
try:
# Get the user from the database
db_user = (
@@ -562,7 +572,9 @@ def edit_user_password(user_id: int, password: str, db: Session, is_hashed: bool
if is_hashed:
db_user.password = password
else:
db_user.password = users_utils.check_password_and_hash(password, 8)
db_user.password = users_utils.check_password_and_hash(
password, password_hasher, 8
)
# Commit the transaction
db.commit()
@@ -784,7 +796,8 @@ def disable_user_mfa(user_id: int, db: Session):
def create_signup_user(
user: users_schema.UserSignup,
server_settings,
server_settings: server_settings_schema.ServerSettingsRead,
password_hasher: session_password_hasher.PasswordHasher,
db: Session,
):
"""
@@ -792,7 +805,8 @@ def create_signup_user(
Args:
user (users_schema.UserSignup): The user signup data containing user details.
server_settings: Server configuration settings that determine signup requirements.
server_settings (server_settings_schema.ServerSettingsRead): Server settings used to determine if email verification or admin approval is required.
password_hasher (session_password_hasher.PasswordHasher): Password hasher used to hash the user's password.
db (Session): SQLAlchemy database session.
Returns:
@@ -842,7 +856,9 @@ def create_signup_user(
currency=user.currency,
email_verified=email_verified,
pending_admin_approval=pending_admin_approval,
password=users_utils.check_password_and_hash(user.password, 8),
password=users_utils.check_password_and_hash(
user.password, password_hasher, 8
),
)
# Add the user to the database

View File

@@ -18,6 +18,7 @@ import health_targets.crud as health_targets_crud
import sign_up_tokens.utils as sign_up_tokens_utils
import session.security as session_security
import session.password_hasher as session_password_hasher
import core.apprise as core_apprise
import core.database as core_database
@@ -138,13 +139,17 @@ async def create_user(
_check_scopes: Annotated[
Callable, Security(session_security.check_scopes, scopes=["users:write"])
],
password_hasher: Annotated[
session_password_hasher.PasswordHasher,
Depends(session_password_hasher.get_password_hasher),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
# Create the user in the database
created_user = users_crud.create_user(user, db)
created_user = users_crud.create_user(user, password_hasher, db)
# Create the user integrations in the database
user_integrations_crud.create_user_integrations(created_user.id, db)
@@ -236,13 +241,19 @@ async def edit_user_password(
_check_scopes: Annotated[
Callable, Security(session_security.check_scopes, scopes=["users:write"])
],
password_hasher: Annotated[
session_password_hasher.PasswordHasher,
Depends(session_password_hasher.get_password_hasher),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
# Update the user password in the database
users_crud.edit_user_password(user_id, user_attributes.password, db)
users_crud.edit_user_password(
user_id, user_attributes.password, password_hasher, db
)
# Return success message
return {f"User ID {user_id} password updated successfully"}

View File

@@ -6,7 +6,6 @@ from sqlalchemy.orm import Session
import shutil
import session.security as session_security
import session.password_hasher as session_password_hasher
import users.user.crud as users_crud
@@ -16,10 +15,14 @@ import core.logger as core_logger
import core.config as core_config
def check_password_and_hash(password: str, min_length: int = 8) -> str:
def check_password_and_hash(
password: str,
password_hasher: session_password_hasher.PasswordHasher,
min_length: int = 8,
) -> str:
# Check if password meets requirements
try:
session_security.password_hasher.validate_password(password, min_length)
password_hasher.validate_password(password, min_length)
except session_password_hasher.PasswordPolicyError as err:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -27,7 +30,7 @@ def check_password_and_hash(password: str, min_length: int = 8) -> str:
) from err
# Hash the password
hashed_password = session_security.password_hasher.hash_password(password)
hashed_password = password_hasher.hash_password(password)
# Return the hashed password
return hashed_password