From c988de19217e58b25df851a7609c07a0f4205396 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sat, 2 Apr 2022 19:33:15 -0800 Subject: [PATCH] fix: group creation (#1126) * fix: unify group creation - closes #1100 * tests: disable password hashing during testing * tests: fix email config tests --- mealie/core/security/__init__.py | 1 + mealie/core/security/hasher.py | 43 +++++++++++++++++++ mealie/core/{ => security}/security.py | 13 +++--- mealie/db/init_db.py | 5 ++- .../routes/admin/admin_management_groups.py | 5 ++- .../services/group_services/group_service.py | 19 ++++++++ mealie/services/group_services/group_utils.py | 18 -------- .../user_services/registration_service.py | 6 +-- tests/unit_tests/core/test_security.py | 22 ++++++++++ .../services_tests/test_email_service.py | 5 ++- tests/unit_tests/test_config.py | 9 ++++ 11 files changed, 113 insertions(+), 33 deletions(-) create mode 100644 mealie/core/security/__init__.py create mode 100644 mealie/core/security/hasher.py rename mealie/core/{ => security}/security.py (90%) delete mode 100644 mealie/services/group_services/group_utils.py create mode 100644 tests/unit_tests/core/test_security.py diff --git a/mealie/core/security/__init__.py b/mealie/core/security/__init__.py new file mode 100644 index 000000000000..cfec0e089c3e --- /dev/null +++ b/mealie/core/security/__init__.py @@ -0,0 +1 @@ +from .security import * diff --git a/mealie/core/security/hasher.py b/mealie/core/security/hasher.py new file mode 100644 index 000000000000..aed59bf47ec8 --- /dev/null +++ b/mealie/core/security/hasher.py @@ -0,0 +1,43 @@ +from functools import lru_cache +from typing import Protocol + +from passlib.context import CryptContext + +from mealie.core.config import get_app_settings + + +class Hasher(Protocol): + def hash(self, password: str) -> str: + ... + + def verify(self, password: str, hashed: str) -> bool: + ... + + +class FakeHasher: + def hash(self, password: str) -> str: + return password + + def verify(self, password: str, hashed: str) -> bool: + return password == hashed + + +class PasslibHasher: + def __init__(self) -> None: + self.ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") + + def hash(self, password: str) -> str: + return self.ctx.hash(password) + + def verify(self, password: str, hashed: str) -> bool: + return self.ctx.verify(password, hashed) + + +@lru_cache(maxsize=1) +def get_hasher() -> Hasher: + settings = get_app_settings() + + if settings.TESTING: + return FakeHasher() + + return PasslibHasher() diff --git a/mealie/core/security.py b/mealie/core/security/security.py similarity index 90% rename from mealie/core/security.py rename to mealie/core/security/security.py index 14845321741d..90f53fd9ec78 100644 --- a/mealie/core/security.py +++ b/mealie/core/security/security.py @@ -1,16 +1,15 @@ import secrets -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from jose import jwt -from passlib.context import CryptContext from mealie.core.config import get_app_settings +from mealie.core.security.hasher import get_hasher from mealie.repos.all_repositories import get_repositories from mealie.repos.repository_factory import AllRepositories from mealie.schema.user import PrivateUser -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") ALGORITHM = "HS256" @@ -20,7 +19,7 @@ def create_access_token(data: dict, expires_delta: timedelta = None) -> str: to_encode = data.copy() expires_delta = expires_delta or timedelta(hours=settings.TOKEN_TIME) - expire = datetime.utcnow() + expires_delta + expire = datetime.now(timezone.utc) + expires_delta to_encode["exp"] = expire return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM) @@ -31,7 +30,7 @@ def create_file_token(file_path: Path) -> str: return create_access_token(token_data, expires_delta=timedelta(minutes=30)) -def create_recipe_slug_token(file_path: str) -> str: +def create_recipe_slug_token(file_path: str | Path) -> str: token_data = {"slug": str(file_path)} return create_access_token(token_data, expires_delta=timedelta(minutes=30)) @@ -96,12 +95,12 @@ def authenticate_user(session, email: str, password: str) -> PrivateUser | bool: def verify_password(plain_password: str, hashed_password: str) -> bool: """Compares a plain string to a hashed password""" - return pwd_context.verify(plain_password, hashed_password) + return get_hasher().verify(plain_password, hashed_password) def hash_password(password: str) -> str: """Takes in a raw password and hashes it. Used prior to saving a new password to the database.""" - return pwd_context.hash(password) + return get_hasher().hash(password) def url_safe_token() -> str: diff --git a/mealie/db/init_db.py b/mealie/db/init_db.py index 8153225d8be5..bfe663241fe8 100644 --- a/mealie/db/init_db.py +++ b/mealie/db/init_db.py @@ -16,7 +16,7 @@ from mealie.repos.repository_factory import AllRepositories from mealie.repos.seed.init_users import default_user_init from mealie.repos.seed.seeders import IngredientFoodsSeeder, IngredientUnitsSeeder, MultiPurposeLabelSeeder from mealie.schema.user.user import GroupBase -from mealie.services.group_services.group_utils import create_new_group +from mealie.services.group_services.group_service import GroupService PROJECT_DIR = Path(__file__).parent.parent.parent @@ -44,7 +44,8 @@ def default_group_init(db: AllRepositories): settings = get_app_settings() logger.info("Generating Default Group") - create_new_group(db, GroupBase(name=settings.DEFAULT_GROUP)) + + GroupService.create_group(db, GroupBase(name=settings.DEFAULT_GROUP)) # Adapted from https://alembic.sqlalchemy.org/en/latest/cookbook.html#test-current-database-revision-is-at-head-s diff --git a/mealie/routes/admin/admin_management_groups.py b/mealie/routes/admin/admin_management_groups.py index d719df06a95a..6f8840c8512c 100644 --- a/mealie/routes/admin/admin_management_groups.py +++ b/mealie/routes/admin/admin_management_groups.py @@ -8,6 +8,7 @@ from mealie.schema.mapper import mapper from mealie.schema.query import GetAll from mealie.schema.response.responses import ErrorResponse from mealie.schema.user.user import GroupBase, GroupInDB +from mealie.services.group_services.group_service import GroupService from .._base import BaseAdminController, controller from .._base.dependencies import SharedDependencies @@ -44,7 +45,7 @@ class AdminUserManagementRoutes(BaseAdminController): @router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED) def create_one(self, data: GroupBase): - return self.mixins.create_one(data) + return GroupService.create_group(self.deps.repos, data) @router.get("/{item_id}", response_model=GroupInDB) def get_one(self, item_id: UUID4): @@ -69,7 +70,7 @@ class AdminUserManagementRoutes(BaseAdminController): def delete_one(self, item_id: UUID4): item = self.repo.get_one(item_id) - if len(item.users) > 0: + if item and len(item.users) > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorResponse.respond(message="Cannot delete group with users"), diff --git a/mealie/services/group_services/group_service.py b/mealie/services/group_services/group_service.py index 48a299c95480..2f2edc09df79 100644 --- a/mealie/services/group_services/group_service.py +++ b/mealie/services/group_services/group_service.py @@ -2,7 +2,9 @@ from pydantic import UUID4 from mealie.pkgs.stats import fs_stats from mealie.repos.repository_factory import AllRepositories +from mealie.schema.group.group_preferences import CreateGroupPreferences from mealie.schema.group.group_statistics import GroupStatistics, GroupStorage +from mealie.schema.user.user import GroupBase from mealie.services._base_service import BaseService ALLOWED_SIZE = 500 * fs_stats.megabyte @@ -14,6 +16,23 @@ class GroupService(BaseService): self.repos = repos super().__init__() + @staticmethod + def create_group(repos: AllRepositories, g_base: GroupBase, prefs: CreateGroupPreferences | None = None): + """ + Creates a new group in the database with the required associated table references to ensure + the group includes required preferences. + """ + new_group = repos.groups.create(g_base) + + if prefs is None: + prefs = CreateGroupPreferences(group_id=new_group.id) + else: + prefs.group_id = new_group.id + + repos.group_preferences.create(prefs) + + return new_group + def calculate_statistics(self, group_id: None | UUID4 = None) -> GroupStatistics: """ calculate_statistics calculates the statistics for the group and returns diff --git a/mealie/services/group_services/group_utils.py b/mealie/services/group_services/group_utils.py deleted file mode 100644 index 3faa95c4102b..000000000000 --- a/mealie/services/group_services/group_utils.py +++ /dev/null @@ -1,18 +0,0 @@ -from uuid import uuid4 - -from mealie.repos.repository_factory import AllRepositories -from mealie.schema.group.group_preferences import CreateGroupPreferences -from mealie.schema.user.user import GroupBase, GroupInDB - - -def create_new_group(db: AllRepositories, g_base: GroupBase, g_preferences: CreateGroupPreferences = None) -> GroupInDB: - created_group = db.groups.create(g_base) - - # Assign Temporary ID before group is created - g_preferences = g_preferences or CreateGroupPreferences(group_id=uuid4()) - - g_preferences.group_id = created_group.id - - db.group_preferences.create(g_preferences) - - return created_group diff --git a/mealie/services/user_services/registration_service.py b/mealie/services/user_services/registration_service.py index 12355db2dee6..b9b881972477 100644 --- a/mealie/services/user_services/registration_service.py +++ b/mealie/services/user_services/registration_service.py @@ -8,7 +8,7 @@ from mealie.repos.repository_factory import AllRepositories from mealie.schema.group.group_preferences import CreateGroupPreferences from mealie.schema.user.registration import CreateUserRegistration from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn -from mealie.services.group_services.group_utils import create_new_group +from mealie.services.group_services.group_service import GroupService class RegistrationService: @@ -19,7 +19,7 @@ class RegistrationService: self.logger = logger self.repos = db - def _create_new_user(self, group: GroupInDB, new_group=bool) -> PrivateUser: + def _create_new_user(self, group: GroupInDB, new_group: bool) -> PrivateUser: new_user = UserIn( email=self.registration.email, username=self.registration.username, @@ -49,7 +49,7 @@ class RegistrationService: recipe_disable_amount=self.registration.advanced, ) - return create_new_group(self.repos, group_data, group_preferences) + return GroupService.create_group(self.repos, group_data, group_preferences) def register_user(self, registration: CreateUserRegistration) -> PrivateUser: self.registration = registration diff --git a/tests/unit_tests/core/test_security.py b/tests/unit_tests/core/test_security.py new file mode 100644 index 000000000000..688157cab7b6 --- /dev/null +++ b/tests/unit_tests/core/test_security.py @@ -0,0 +1,22 @@ +from pytest import MonkeyPatch + +from mealie.core.config import get_app_settings +from mealie.core.security.hasher import FakeHasher, PasslibHasher, get_hasher + + +def test_get_hasher(monkeypatch: MonkeyPatch): + hasher = get_hasher() + + assert isinstance(hasher, FakeHasher) + + monkeypatch.setenv("TESTING", "0") + + get_hasher.cache_clear() + get_app_settings.cache_clear() + + hasher = get_hasher() + + assert isinstance(hasher, PasslibHasher) + + get_app_settings.cache_clear() + get_hasher.cache_clear() diff --git a/tests/unit_tests/services_tests/test_email_service.py b/tests/unit_tests/services_tests/test_email_service.py index c5fb29c94d41..3f09dddf0b88 100644 --- a/tests/unit_tests/services_tests/test_email_service.py +++ b/tests/unit_tests/services_tests/test_email_service.py @@ -44,8 +44,11 @@ def email_service(monkeypatch) -> EmailService: return email_service -def test_email_disabled(): +def test_email_disabled(monkeypatch): email_service = EmailService(TestEmailSender()) + + monkeypatch.setenv("SMTP_HOST", "") # disable email + get_app_settings.cache_clear() email_service.settings = get_app_settings() success = email_service.send_test_email(FAKE_ADDRESS) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 87e96f5ff013..695ad7f173e0 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -60,8 +60,17 @@ def test_pg_connection_args(monkeypatch): def test_smtp_enable(monkeypatch): + monkeypatch.setenv("SMTP_HOST", "") + monkeypatch.setenv("SMTP_PORT", "") + monkeypatch.setenv("SMTP_TLS", "true") + monkeypatch.setenv("SMTP_FROM_NAME", "") + monkeypatch.setenv("SMTP_FROM_EMAIL", "") + monkeypatch.setenv("SMTP_USER", "") + monkeypatch.setenv("SMTP_PASSWORD", "") + get_app_settings.cache_clear() app_settings = get_app_settings() + assert app_settings.SMTP_ENABLE is False monkeypatch.setenv("SMTP_HOST", "email.mealie.io")