Coverage for opt/mealie/lib/python3.12/site-packages/mealie/core/security/providers/credentials_provider.py: 72%

43 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-11-25 15:32 +0000

1from datetime import timedelta 1a

2 

3from sqlalchemy.orm.session import Session 1a

4 

5from mealie.core import root_logger 1a

6from mealie.core.config import get_app_settings 1a

7from mealie.core.exceptions import UserLockedOut 1a

8from mealie.core.security.hasher import get_hasher 1a

9from mealie.core.security.providers.auth_provider import AuthProvider 1a

10from mealie.db.models.users.users import AuthMethod 1a

11from mealie.repos.all_repositories import get_repositories 1a

12from mealie.schema.user.auth import CredentialsRequest 1a

13from mealie.services.user_services.user_service import UserService 1a

14 

15 

16class CredentialsProvider(AuthProvider[CredentialsRequest]): 1a

17 """Authentication provider that authenticates a user the database using username/password combination""" 

18 

19 _logger = root_logger.get_logger("credentials_provider") 1a

20 

21 def __init__(self, session: Session, data: CredentialsRequest) -> None: 1a

22 super().__init__(session, data) 1cdefghbijk

23 

24 def authenticate(self) -> tuple[str, timedelta] | None: 1a

25 """Attempt to authenticate a user given a username and password""" 

26 settings = get_app_settings() 1cdefghbijk

27 db = get_repositories(self.session, group_id=None, household_id=None) 1cdefghbijk

28 user = self.try_get_user(self.data.username) 1cdefghbijk

29 

30 if not user: 1cdefghbijk

31 self.verify_fake_password() 1defghbijlk

32 return None 1defghbijlk

33 

34 if user.auth_method != AuthMethod.MEALIE: 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true1cb

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 

40 

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 true1cb

42 raise UserLockedOut() 

43 

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 true1cb

45 user.login_attemps += 1 

46 db.users.update(user.id, user) 

47 

48 if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS: 

49 user_service = UserService(db) 

50 user_service.lock_user(user) 

51 

52 return None 

53 

54 user.login_attemps = 0 1cb

55 user = db.users.update(user.id, user) 1cb

56 return self.get_access_token(user, self.data.remember_me) # type: ignore 1cb

57 

58 def verify_fake_password(self): 1a

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( 1defghbijlk

62 "abc123cba321", 

63 "$2b$12$JdHtJOlkPFwyxdjdygEzPOtYmdQF5/R5tHxw5Tq8pxjubyLqdIX5i", 

64 ) 

65 

66 @staticmethod 1a

67 def verify_password(plain_password: str, hashed_password: str) -> bool: 1a

68 """Compares a plain string to a hashed password""" 

69 return get_hasher().verify(plain_password, hashed_password) 1cdefghbijlk