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:
Hayden
2022-08-13 13:18:12 -08:00
committed by GitHub
parent ca64584fd1
commit b3c41a4bd0
35 changed files with 450 additions and 46 deletions

View File

@@ -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"}

View File

@@ -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",
]
"""

View 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")

View File

@@ -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

View 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)