mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-01 13:33:11 -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:
@@ -10,7 +10,6 @@ from mealie.routes.handlers import register_debug_handler
|
||||
from mealie.routes.media import media_router
|
||||
from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks
|
||||
|
||||
logger = get_logger()
|
||||
settings = get_app_settings()
|
||||
|
||||
description = f"""
|
||||
@@ -61,6 +60,10 @@ async def start_scheduler():
|
||||
tasks.post_group_webhooks,
|
||||
)
|
||||
|
||||
SchedulerRegistry.register_hourly(
|
||||
tasks.locked_user_reset,
|
||||
)
|
||||
|
||||
SchedulerRegistry.print_jobs()
|
||||
|
||||
await SchedulerService.start()
|
||||
@@ -77,6 +80,8 @@ api_routers()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def system_startup():
|
||||
logger = get_logger()
|
||||
|
||||
await start_scheduler()
|
||||
|
||||
logger.info("-----SYSTEM STARTUP----- \n")
|
||||
|
||||
@@ -9,10 +9,15 @@ 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
|
||||
from mealie.services.user_services.user_service import UserService
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
class UserLockedOut(Exception):
|
||||
...
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
|
||||
settings = get_app_settings()
|
||||
|
||||
@@ -35,7 +40,7 @@ def create_recipe_slug_token(file_path: str | Path) -> str:
|
||||
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
|
||||
|
||||
|
||||
def user_from_ldap(db: AllRepositories, session, username: str, password: str) -> PrivateUser | bool:
|
||||
def user_from_ldap(db: AllRepositories, username: str, password: str) -> PrivateUser | bool:
|
||||
"""Given a username and password, tries to authenticate by BINDing to an
|
||||
LDAP server
|
||||
|
||||
@@ -85,7 +90,7 @@ def authenticate_user(session, email: str, password: str) -> PrivateUser | bool:
|
||||
user = db.users.get_one(email, "username", any_case=True)
|
||||
|
||||
if settings.LDAP_AUTH_ENABLED and (not user or user.password == "LDAP"):
|
||||
return user_from_ldap(db, session, email, password)
|
||||
return user_from_ldap(db, email, password)
|
||||
|
||||
if not user:
|
||||
# To prevent user enumeration we perform the verify_password computation to ensure
|
||||
@@ -93,7 +98,17 @@ def authenticate_user(session, email: str, password: str) -> PrivateUser | bool:
|
||||
verify_password("abc123cba321", "$2b$12$JdHtJOlkPFwyxdjdygEzPOtYmdQF5/R5tHxw5Tq8pxjubyLqdIX5i")
|
||||
return False
|
||||
|
||||
if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS or user.is_locked:
|
||||
raise UserLockedOut()
|
||||
|
||||
elif not verify_password(password, user.password):
|
||||
user.login_attemps += 1
|
||||
db.users.update(user.id, user)
|
||||
|
||||
if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS:
|
||||
user_service = UserService(db)
|
||||
user_service.lock_user(user)
|
||||
|
||||
return False
|
||||
|
||||
return user
|
||||
|
||||
@@ -36,6 +36,12 @@ class AppSettings(BaseSettings):
|
||||
|
||||
ALLOW_SIGNUP: bool = True
|
||||
|
||||
# ===============================================
|
||||
# Security Configuration
|
||||
|
||||
SECURITY_MAX_LOGIN_ATTEMPTS: int = 5
|
||||
SECURITY_USER_LOCKOUT_TIME: int = 24 # Time in Hours
|
||||
|
||||
@property
|
||||
def DOCS_URL(self) -> str | None:
|
||||
return "/docs" if self.API_DOCS else None
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -29,6 +30,17 @@ def sql_global_init(db_url: str):
|
||||
SessionLocal, engine = sql_global_init(settings.DB_URL) # type: ignore
|
||||
|
||||
|
||||
@contextmanager
|
||||
def with_session() -> Session:
|
||||
global SessionLocal
|
||||
sess = SessionLocal()
|
||||
|
||||
try:
|
||||
yield sess
|
||||
finally:
|
||||
sess.close()
|
||||
|
||||
|
||||
def create_session() -> Session:
|
||||
global SessionLocal
|
||||
return SessionLocal()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, String, orm
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, orm
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
@@ -36,6 +36,8 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
group = orm.relationship("Group", back_populates="users")
|
||||
|
||||
cache_key = Column(String, default="1234")
|
||||
login_attemps = Column(Integer, default=0)
|
||||
locked_at = Column(DateTime, default=None)
|
||||
|
||||
# Group Permissions
|
||||
can_manage = Column(Boolean, default=False)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import random
|
||||
import shutil
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import UUID4
|
||||
|
||||
@@ -38,8 +37,10 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
|
||||
shutil.rmtree(PrivateUser.get_directory(value))
|
||||
return entry # type: ignore
|
||||
|
||||
def get_by_username(self, username: str, limit=1) -> Optional[User]:
|
||||
def get_by_username(self, username: str) -> PrivateUser | None:
|
||||
dbuser = self.session.query(User).filter(User.username == username).one_or_none()
|
||||
if dbuser is None:
|
||||
return None
|
||||
return self.schema.from_orm(dbuser) # type: ignore
|
||||
return None if dbuser is None else self.schema.from_orm(dbuser)
|
||||
|
||||
def get_locked_users(self) -> list[PrivateUser]:
|
||||
results = self.session.query(User).filter(User.locked_at != None).all() # noqa E711
|
||||
return [self.schema.from_orm(x) for x in results]
|
||||
|
||||
@@ -8,7 +8,9 @@ from mealie.routes._base import BaseAdminController, controller
|
||||
from mealie.routes._base.mixins import HttpRepo
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.response.responses import ErrorResponse
|
||||
from mealie.schema.user.auth import UnlockResults
|
||||
from mealie.schema.user.user import UserIn, UserOut, UserPagination
|
||||
from mealie.services.user_services.user_service import UserService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Admin: Users"])
|
||||
|
||||
@@ -17,9 +19,6 @@ router = APIRouter(prefix="/users", tags=["Admin: Users"])
|
||||
class AdminUserManagementRoutes(BaseAdminController):
|
||||
@cached_property
|
||||
def repo(self):
|
||||
if not self.user:
|
||||
raise Exception("No user is logged in.")
|
||||
|
||||
return self.repos.users
|
||||
|
||||
# =======================================================================
|
||||
@@ -44,6 +43,13 @@ class AdminUserManagementRoutes(BaseAdminController):
|
||||
data.password = security.hash_password(data.password)
|
||||
return self.mixins.create_one(data)
|
||||
|
||||
@router.post("/unlock", response_model=UnlockResults)
|
||||
def unlock_users(self, force: bool = False) -> UnlockResults:
|
||||
user_service = UserService(self.repos)
|
||||
unlocked = user_service.reset_locked_users(force=force)
|
||||
|
||||
return UnlockResults(unlocked=unlocked)
|
||||
|
||||
@router.get("/{item_id}", response_model=UserOut)
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.mixins.get_one(item_id)
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm.session import Session
|
||||
from mealie.core import security
|
||||
from mealie.core.dependencies import get_current_user
|
||||
from mealie.core.security import authenticate_user
|
||||
from mealie.core.security.security import UserLockedOut
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.schema.user import PrivateUser
|
||||
@@ -53,7 +54,10 @@ def get_token(data: CustomOAuth2Form = Depends(), session: Session = Depends(gen
|
||||
email = data.username
|
||||
password = data.password
|
||||
|
||||
user = authenticate_user(session, email, password) # type: ignore
|
||||
try:
|
||||
user = authenticate_user(session, email, password) # type: ignore
|
||||
except UserLockedOut as e:
|
||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="User is locked out") from e
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Optional
|
||||
from pydantic import UUID4, BaseModel
|
||||
from pydantic.types import constr
|
||||
|
||||
from mealie.schema._mealie.mealie_model import MealieModel
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
@@ -12,3 +14,7 @@ class Token(BaseModel):
|
||||
class TokenData(BaseModel):
|
||||
user_id: Optional[UUID4]
|
||||
username: Optional[constr(to_lower=True, strip_whitespace=True)] = None # type: ignore
|
||||
|
||||
|
||||
class UnlockResults(MealieModel):
|
||||
unlocked: int = 0
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import UUID4
|
||||
from pydantic import UUID4, validator
|
||||
from pydantic.types import constr
|
||||
from pydantic.utils import GetterDict
|
||||
|
||||
@@ -137,16 +138,30 @@ class UserFavorites(UserBase):
|
||||
class PrivateUser(UserOut):
|
||||
password: str
|
||||
group_id: UUID4
|
||||
login_attemps: int = 0
|
||||
locked_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@validator("login_attemps", pre=True)
|
||||
def none_to_zero(cls, v):
|
||||
return 0 if v is None else v
|
||||
|
||||
@staticmethod
|
||||
def get_directory(user_id: UUID4 | str) -> Path:
|
||||
user_dir = get_app_dirs().USER_DIR / str(user_id)
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
return user_dir
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool:
|
||||
if self.locked_at is None:
|
||||
return False
|
||||
|
||||
lockout_expires_at = self.locked_at + timedelta(hours=get_app_settings().SECURITY_USER_LOCKOUT_TIME)
|
||||
return lockout_expires_at > datetime.now()
|
||||
|
||||
def directory(self) -> Path:
|
||||
return PrivateUser.get_directory(self.id)
|
||||
|
||||
@@ -168,15 +183,15 @@ class GroupInDB(UpdateGroup):
|
||||
|
||||
@staticmethod
|
||||
def get_directory(id: UUID4) -> Path:
|
||||
dir = get_app_dirs().GROUPS_DIR / str(id)
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
return dir
|
||||
group_dir = get_app_dirs().GROUPS_DIR / str(id)
|
||||
group_dir.mkdir(parents=True, exist_ok=True)
|
||||
return group_dir
|
||||
|
||||
@staticmethod
|
||||
def get_export_directory(id: UUID) -> Path:
|
||||
dir = GroupInDB.get_directory(id) / "export"
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
return dir
|
||||
export_dir = GroupInDB.get_directory(id) / "export"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
return export_dir
|
||||
|
||||
@property
|
||||
def directory(self) -> Path:
|
||||
|
||||
34
mealie/scripts/reset_locked_users.py
Normal file
34
mealie/scripts/reset_locked_users.py
Normal file
@@ -0,0 +1,34 @@
|
||||
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 main():
|
||||
confirmed = input("Are you sure you want to reset all locked users? (y/n) ")
|
||||
|
||||
if confirmed != "y":
|
||||
print("aborting") # noqa
|
||||
exit(0)
|
||||
|
||||
logger = root_logger.get_logger()
|
||||
|
||||
with with_session() as session:
|
||||
repos = AllRepositories(session)
|
||||
user_service = UserService(repos)
|
||||
|
||||
locked_users = user_service.get_locked_users()
|
||||
|
||||
if not locked_users:
|
||||
logger.error("no locked users found")
|
||||
|
||||
for user in locked_users:
|
||||
logger.info(f"unlocking user {user.username}")
|
||||
user_service.unlock_user(user)
|
||||
|
||||
input("press enter to exit ")
|
||||
exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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