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

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