mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-01 21:43:28 -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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user