mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
fix: group creation (#1126)
* fix: unify group creation - closes #1100 * tests: disable password hashing during testing * tests: fix email config tests
This commit is contained in:
parent
e9bb39c744
commit
c988de1921
1
mealie/core/security/__init__.py
Normal file
1
mealie/core/security/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .security import *
|
43
mealie/core/security/hasher.py
Normal file
43
mealie/core/security/hasher.py
Normal file
@ -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()
|
@ -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:
|
@ -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
|
||||
|
@ -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"),
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
22
tests/unit_tests/core/test_security.py
Normal file
22
tests/unit_tests/core/test_security.py
Normal file
@ -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()
|
@ -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)
|
||||
|
@ -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")
|
||||
|
Loading…
x
Reference in New Issue
Block a user