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

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

View File

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