mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-11 21:05:15 -05:00
security: implement user lockout (#1552)
* add data-types required for login security * implement user lockout checking at login * cleanup legacy patterns * expose passwords in test_user * test user lockout after bad attempts * test user service * bump alembic version * save increment to database * add locked_at to datetime transformer on import * do proper test cleanup * implement scheduled task * spelling * document env variables * implement context manager for session * use context manager * implement reset script * cleanup generator * run generator * implement API endpoint for resetting locked users * add button to reset all locked users * add info when account is locked * use ignore instead of expect-error
This commit is contained in:
@@ -16,7 +16,7 @@ class AlchemyExporter(BaseService):
|
||||
engine: base.Engine
|
||||
meta: MetaData
|
||||
|
||||
look_for_datetime = {"created_at", "update_at", "date_updated", "timestamp", "expires_at"}
|
||||
look_for_datetime = {"created_at", "update_at", "date_updated", "timestamp", "expires_at", "locked_at"}
|
||||
look_for_date = {"date_added", "date"}
|
||||
look_for_time = {"scheduled_time"}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ from .post_webhooks import post_group_webhooks
|
||||
from .purge_group_exports import purge_group_data_exports
|
||||
from .purge_password_reset import purge_password_reset_tokens
|
||||
from .purge_registration import purge_group_registration
|
||||
from .reset_locked_users import locked_user_reset
|
||||
|
||||
__all__ = [
|
||||
"post_group_webhooks",
|
||||
"purge_password_reset_tokens",
|
||||
"purge_group_data_exports",
|
||||
"purge_group_registration",
|
||||
"locked_user_reset",
|
||||
]
|
||||
|
||||
"""
|
||||
|
||||
17
mealie/services/scheduler/tasks/reset_locked_users.py
Normal file
17
mealie/services/scheduler/tasks/reset_locked_users.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from mealie.core import root_logger
|
||||
from mealie.db.db_setup import with_session
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.services.user_services.user_service import UserService
|
||||
|
||||
|
||||
def locked_user_reset():
|
||||
logger = root_logger.get_logger()
|
||||
logger.info("resetting locked users")
|
||||
|
||||
with with_session() as session:
|
||||
repos = AllRepositories(session)
|
||||
user_service = UserService(repos)
|
||||
|
||||
unlocked = user_service.reset_locked_users()
|
||||
logger.info(f"scheduled task unlocked {unlocked} users in the database")
|
||||
logger.info("locked users reset")
|
||||
@@ -1,15 +1,12 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.core.security import hash_password, url_safe_token
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.user.user_passwords import SavePasswordResetToken
|
||||
from mealie.services._base_service import BaseService
|
||||
from mealie.services.email import EmailService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PasswordResetService(BaseService):
|
||||
def __init__(self, session: Session) -> None:
|
||||
@@ -20,7 +17,7 @@ class PasswordResetService(BaseService):
|
||||
user = self.db.users.get_one(email, "email", any_case=True)
|
||||
|
||||
if user is None:
|
||||
logger.error(f"failed to create password reset for {email=}: user doesn't exists")
|
||||
self.logger.error(f"failed to create password reset for {email=}: user doesn't exists")
|
||||
# Do not raise exception here as we don't want to confirm to the client that the Email doens't exists
|
||||
return None
|
||||
|
||||
@@ -41,7 +38,7 @@ class PasswordResetService(BaseService):
|
||||
try:
|
||||
email_servive.send_forgot_password(email, reset_url)
|
||||
except Exception as e:
|
||||
logger.error(f"failed to send reset email: {e}")
|
||||
self.logger.error(f"failed to send reset email: {e}")
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to send reset email")
|
||||
|
||||
def reset_password(self, token: str, new_password: str):
|
||||
@@ -49,7 +46,7 @@ class PasswordResetService(BaseService):
|
||||
token_entry = self.db.tokens_pw_reset.get_one(token, "token")
|
||||
|
||||
if token_entry is None:
|
||||
logger.error("failed to reset password: invalid token")
|
||||
self.logger.error("failed to reset password: invalid token")
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid token")
|
||||
|
||||
user = self.db.users.get_one(token_entry.user_id)
|
||||
@@ -59,7 +56,7 @@ class PasswordResetService(BaseService):
|
||||
new_user = self.db.users.update_password(user.id, password_hash)
|
||||
# Confirm Password
|
||||
if new_user.password != password_hash:
|
||||
logger.error("failed to reset password: invalid password")
|
||||
self.logger.error("failed to reset password: invalid password")
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid password")
|
||||
|
||||
# Delete Token from DB
|
||||
|
||||
39
mealie/services/user_services/user_service.py
Normal file
39
mealie/services/user_services/user_service.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
from mealie.services._base_service import BaseService
|
||||
|
||||
|
||||
class UserService(BaseService):
|
||||
def __init__(self, repos: AllRepositories) -> None:
|
||||
self.repos = repos
|
||||
super().__init__()
|
||||
|
||||
def get_locked_users(self) -> list[PrivateUser]:
|
||||
return self.repos.users.get_locked_users()
|
||||
|
||||
def reset_locked_users(self, force: bool = False) -> int:
|
||||
"""
|
||||
Queriers that database for all locked users and resets their locked_at field to None
|
||||
if more than the set time has passed since the user was locked
|
||||
"""
|
||||
locked_users = self.get_locked_users()
|
||||
|
||||
unlocked = 0
|
||||
|
||||
for user in locked_users:
|
||||
if force or user.is_locked and user.locked_at is not None:
|
||||
self.unlock_user(user)
|
||||
unlocked += 1
|
||||
|
||||
return unlocked
|
||||
|
||||
def lock_user(self, user: PrivateUser) -> PrivateUser:
|
||||
user.locked_at = datetime.now()
|
||||
return self.repos.users.update(user.id, user)
|
||||
|
||||
def unlock_user(self, user: PrivateUser) -> PrivateUser:
|
||||
user.locked_at = None
|
||||
user.login_attemps = 0
|
||||
return self.repos.users.update(user.id, user)
|
||||
Reference in New Issue
Block a user