Coverage for opt/mealie/lib/python3.12/site-packages/mealie/core/security/providers/credentials_provider.py: 64%
43 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
1from datetime import timedelta
3from sqlalchemy.orm.session import Session
5from mealie.core import root_logger
6from mealie.core.config import get_app_settings
7from mealie.core.exceptions import UserLockedOut
8from mealie.core.security.hasher import get_hasher
9from mealie.core.security.providers.auth_provider import AuthProvider
10from mealie.db.models.users.users import AuthMethod
11from mealie.repos.all_repositories import get_repositories
12from mealie.schema.user.auth import CredentialsRequest
13from mealie.services.user_services.user_service import UserService
16class CredentialsProvider(AuthProvider[CredentialsRequest]):
17 """Authentication provider that authenticates a user the database using username/password combination"""
19 _logger = root_logger.get_logger("credentials_provider")
21 def __init__(self, session: Session, data: CredentialsRequest) -> None:
22 super().__init__(session, data)
24 def authenticate(self) -> tuple[str, timedelta] | None:
25 """Attempt to authenticate a user given a username and password"""
26 settings = get_app_settings()
27 db = get_repositories(self.session, group_id=None, household_id=None)
28 user = self.try_get_user(self.data.username)
30 if not user: 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true
31 self.verify_fake_password()
32 return None
34 if user.auth_method != AuthMethod.MEALIE: 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true
35 self.verify_fake_password()
36 self._logger.warning(
37 "Found user but their auth method is not 'Mealie'. Unable to continue with credentials login"
38 )
39 return None
41 if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS or user.is_locked: 41 ↛ 42line 41 didn't jump to line 42 because the condition on line 41 was never true
42 raise UserLockedOut()
44 if not CredentialsProvider.verify_password(self.data.password, user.password): 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true
45 user.login_attemps += 1
46 db.users.update(user.id, user)
48 if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS:
49 user_service = UserService(db)
50 user_service.lock_user(user)
52 return None
54 user.login_attemps = 0
55 user = db.users.update(user.id, user)
56 return self.get_access_token(user, self.data.remember_me) # type: ignore
58 def verify_fake_password(self):
59 # To prevent user enumeration we perform the verify_password computation to ensure
60 # server side time is relatively constant and not vulnerable to timing attacks.
61 CredentialsProvider.verify_password(
62 "abc123cba321",
63 "$2b$12$JdHtJOlkPFwyxdjdygEzPOtYmdQF5/R5tHxw5Tq8pxjubyLqdIX5i",
64 )
66 @staticmethod
67 def verify_password(plain_password: str, hashed_password: str) -> bool:
68 """Compares a plain string to a hashed password"""
69 return get_hasher().verify(plain_password, hashed_password)