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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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)