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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 450 additions and 46 deletions

View File

@ -0,0 +1,26 @@
"""add login_attemps and locked_at field to user table
Revision ID: 188374910655
Revises: f30cf048c228
Create Date: 2022-08-12 19:05:59.776361
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "188374910655"
down_revision = "f30cf048c228"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("users", sa.Column("login_attemps", sa.Integer(), nullable=True))
op.add_column("users", sa.Column("locked_at", sa.DateTime(), nullable=True))
def downgrade():
op.drop_column("users", "locked_at")
op.drop_column("users", "login_attemps")

View File

@ -99,24 +99,24 @@ def generate_typescript_types() -> None:
generate_typescript_defs(path_as_module, str(out_path), exclude=("MealieModel")) # type: ignore
except Exception as e:
failed_modules.append(module)
log.error(f"Module Error: {e}") # noqa
log.error(f"Module Error: {e}")
log.info("\n📁 Skipped Directories:") # noqa
log.info("\n📁 Skipped Directories:")
for skipped_dir in skipped_dirs:
log.info(" 📁", skipped_dir.name) # noqa
log.info(f" 📁 {skipped_dir.name}")
log.info("📄 Skipped Files:") # noqa
log.info("📄 Skipped Files:")
for f in skipped_files:
log.info(" 📄", f.name) # noqa
log.info(f" 📄 {f.name}")
log.error("❌ Failed Modules:") # noqa
log.error("❌ Failed Modules:")
for f in failed_modules:
log.error("", f.name) # noqa
log.error(f"{f.name}")
if __name__ == "__main__":
log.info("\n-- Starting Global Components Generator --") # noqa
log.info("\n-- Starting Global Components Generator --")
generate_global_components_types()
log.info("\n-- Starting Pydantic To Typescript Generator --") # noqa
log.info("\n-- Starting Pydantic To Typescript Generator --")
generate_typescript_types()

View File

@ -18,7 +18,12 @@
| ALLOW_SIGNUP | true | Allow user sign-up without token (should match frontend env) |
### Security
| Variables | Default | Description |
| --------------------------- | :-----: | ----------------------------------------------------------------------------------- |
| SECURITY_MAX_LOGIN_ATTEMPTS | 5 | Maximum times a user can provide an invalid password before their account is locked |
| SECURITY_USER_LOCKOUT_TIME | 24 | Time in hours for how long a users account is locked |
### Database
@ -39,7 +44,7 @@
| SMTP_HOST | None | Required For email |
| SMTP_PORT | 587 | Required For email |
| SMTP_FROM_NAME | Mealie | Required For email |
| SMTP_AUTH_STRATEGY | TLS | Required For email, Options: 'TLS', 'SSL', 'NONE' |
| SMTP_AUTH_STRATEGY | TLS | Required For email, Options: 'TLS', 'SSL', 'NONE' |
| SMTP_FROM_EMAIL | None | Required For email |
| SMTP_USER | None | Required if SMTP_AUTH_STRATEGY is 'TLS' or 'SSL' |
| SMTP_PASSWORD | None | Required if SMTP_AUTH_STRATEGY is 'TLS' or 'SSL' |

View File

@ -1,14 +1,19 @@
import { BaseCRUDAPI } from "../_base";
import { UserIn, UserOut } from "~/types/api-types/user";
import { UnlockResults, UserIn, UserOut } from "~/types/api-types/user";
const prefix = "/api";
const routes = {
adminUsers: `${prefix}/admin/users`,
adminUsersId: (tag: string) => `${prefix}/admin/users/${tag}`,
adminResetLockedUsers: (force: boolean) => `${prefix}/admin/users/unlock?force=${force ? "true" : "false"}`,
};
export class AdminUsersApi extends BaseCRUDAPI<UserIn, UserOut, UserOut> {
baseRoute: string = routes.adminUsers;
itemRoute = routes.adminUsersId;
async unlockAllUsers(force = false) {
return await this.requests.post<UnlockResults>(routes.adminResetLockedUsers(force), {});
}
}

View File

@ -10,9 +10,22 @@
<BaseCardSectionTitle title="User Management"> </BaseCardSectionTitle>
<section>
<v-toolbar color="background" flat class="justify-between">
<BaseButton to="/admin/manage/users/create">
<BaseButton to="/admin/manage/users/create" class="mr-2">
{{ $t("general.create") }}
</BaseButton>
<BaseOverflowButton
mode="event"
:items="[
{
text: 'Reset Locked Users',
icon: $globals.icons.lock,
event: 'unlock-all-users',
},
]"
@unlock-all-users="unlockAllUsers"
>
</BaseOverflowButton>
</v-toolbar>
<v-data-table
:headers="headers"
@ -53,14 +66,15 @@
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { useUser, useAllUsers } from "~/composables/use-user";
import { UserOut } from "~/types/api-types/user";
export default defineComponent({
layout: "admin",
setup() {
const api = useUserApi();
const api = useAdminApi();
const refUserDialog = ref();
const { i18n } = useContext();
@ -97,9 +111,20 @@ export default defineComponent({
{ text: i18n.t("general.delete"), value: "actions", sortable: false, align: "center" },
];
async function unlockAllUsers(): Promise<void> {
const { data } = await api.users.unlockAllUsers(true);
if (data) {
const unlocked = data.unlocked ?? 0;
alert.success(`${unlocked} user(s) unlocked`);
refreshAllUsers();
}
}
return {
unlockAllUsers,
...toRefs(state),
api,
headers,
deleteUser,
loading,

View File

@ -157,9 +157,12 @@ export default defineComponent({
// See https://github.com/nuxt-community/axios-module/issues/550
// Import $axios from useContext()
// if ($axios.isAxiosError(error) && error.response?.status === 401) {
// @ts-ignore - see above
// @ts-ignore- see above
if (error.response?.status === 401) {
alert.error("Invalid Credentials");
// @ts-ignore - see above
} else if (error.response?.status === 423) {
alert.error("Account Locked. Please try again later");
} else {
alert.error("Something Went Wrong!");
}

View File

@ -101,6 +101,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCategory {
id?: string;
@ -135,6 +137,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
@ -149,6 +153,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;

View File

@ -86,6 +86,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCategory {
id?: string;
@ -114,6 +116,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
@ -128,6 +132,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;

View File

@ -197,6 +197,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
@ -211,6 +213,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface ReadGroupPreferences {
privateGroup?: boolean;
@ -259,6 +263,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCategory {
id?: string;
@ -322,6 +328,8 @@ export interface SetPermissions {
}
export interface ShoppingListCreate {
name?: string;
createdAt?: string;
updateAt?: string;
}
export interface ShoppingListItemCreate {
shoppingListId: string;
@ -336,6 +344,8 @@ export interface ShoppingListItemCreate {
food?: IngredientFood;
labelId?: string;
recipeReferences?: ShoppingListItemRecipeRef[];
createdAt?: string;
updateAt?: string;
}
export interface ShoppingListItemRecipeRef {
recipeId: string;
@ -353,7 +363,9 @@ export interface ShoppingListItemOut {
foodId?: string;
food?: IngredientFood;
labelId?: string;
recipeReferences?: ShoppingListItemRecipeRefOut[];
recipeReferences?: (ShoppingListItemRecipeRef | ShoppingListItemRecipeRefOut)[];
createdAt?: string;
updateAt?: string;
id: string;
label?: MultiPurposeLabelSummary;
}
@ -376,10 +388,14 @@ export interface ShoppingListItemUpdate {
food?: IngredientFood;
labelId?: string;
recipeReferences?: ShoppingListItemRecipeRef[];
createdAt?: string;
updateAt?: string;
id: string;
}
export interface ShoppingListOut {
name?: string;
createdAt?: string;
updateAt?: string;
groupId: string;
id: string;
listItems?: ShoppingListItemOut[];
@ -394,15 +410,21 @@ export interface ShoppingListRecipeRefOut {
}
export interface ShoppingListSave {
name?: string;
createdAt?: string;
updateAt?: string;
groupId: string;
}
export interface ShoppingListSummary {
name?: string;
createdAt?: string;
updateAt?: string;
groupId: string;
id: string;
}
export interface ShoppingListUpdate {
name?: string;
createdAt?: string;
updateAt?: string;
groupId: string;
id: string;
listItems?: ShoppingListItemOut[];

View File

@ -116,6 +116,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCategory {
id?: string;
@ -150,6 +152,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
@ -164,6 +168,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;

View File

@ -7,6 +7,7 @@
export type ExportTypes = "json";
export type RegisteredParser = "nlp" | "brute";
export type OrderDirection = "asc" | "desc";
export interface AssignCategories {
recipes: string[];
@ -96,6 +97,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
@ -120,6 +123,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface IngredientsRequest {
parser?: RegisteredParser & string;
@ -142,6 +147,13 @@ export interface Nutrition {
sodiumContent?: string;
sugarContent?: string;
}
export interface PaginationQuery {
page?: number;
perPage?: number;
orderBy?: string;
orderDirection?: OrderDirection & string;
queryFilter?: string;
}
export interface ParsedIngredient {
input?: string;
confidence?: IngredientConfidence;
@ -178,6 +190,8 @@ export interface Recipe {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
recipeInstructions?: RecipeStep[];
nutrition?: Nutrition;
settings?: RecipeSettings;
@ -259,6 +273,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCommentCreate {
recipeId: string;
@ -273,6 +289,14 @@ export interface RecipeCommentUpdate {
id: string;
text: string;
}
export interface RecipePaginationQuery {
page?: number;
perPage?: number;
orderBy?: string;
orderDirection?: OrderDirection & string;
queryFilter?: string;
loadFood?: boolean;
}
export interface RecipeShareToken {
recipeId: string;
expiresAt?: string;

View File

@ -5,6 +5,8 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type OrderDirection = "asc" | "desc";
export interface ErrorResponse {
message: string;
error?: boolean;
@ -13,6 +15,13 @@ export interface ErrorResponse {
export interface FileTokenResponse {
fileToken: string;
}
export interface PaginationQuery {
page?: number;
perPage?: number;
orderBy?: string;
orderDirection?: OrderDirection & string;
queryFilter?: string;
}
export interface SuccessResponse {
message: string;
error?: boolean;

View File

@ -107,6 +107,8 @@ export interface PrivateUser {
tokens?: LongLiveTokenOut[];
cacheKey: string;
password: string;
loginAttemps?: number;
lockedAt?: string;
}
export interface PrivatePasswordResetToken {
userId: string;
@ -134,6 +136,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCategory {
id?: string;
@ -168,6 +172,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
@ -182,6 +188,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
@ -212,6 +220,9 @@ export interface TokenData {
user_id?: string;
username?: string;
}
export interface UnlockResults {
unlocked?: number;
}
export interface UpdateGroup {
name: string;
id: string;

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)

View File

@ -32,6 +32,7 @@ def admin_user(api_client: TestClient, api_routes: utils.AppRoutes):
yield utils.TestUser(
_group_id=user_data.get("groupId"),
user_id=user_data.get("id"),
password=settings.DEFAULT_PASSWORD,
username=user_data.get("username"),
email=user_data.get("email"),
token=token,

View File

@ -26,6 +26,7 @@ def build_unique_user(group: str, api_client: TestClient) -> utils.TestUser:
_group_id=user_data.get("groupId"),
user_id=user_data.get("id"),
email=user_data.get("email"),
password=registration.password,
username=user_data.get("username"),
token=token,
)
@ -67,6 +68,7 @@ def g2_user(admin_token, api_client: TestClient, api_routes: utils.AppRoutes):
user_id=user_id,
_group_id=group_id,
token=token,
password="useruser",
email=create_data["email"],
username=create_data.get("username"),
)
@ -92,6 +94,7 @@ def unique_user(api_client: TestClient, api_routes: utils.AppRoutes):
yield utils.TestUser(
_group_id=user_data.get("groupId"),
user_id=user_data.get("id"),
password=registration.password,
email=user_data.get("email"),
username=user_data.get("username"),
token=token,
@ -144,6 +147,7 @@ def user_tuple(admin_token, api_client: TestClient, api_routes: utils.AppRoutes)
_group_id=user_data.get("groupId"),
user_id=user_data.get("id"),
username=user_data.get("username"),
password="useruser",
email=user_data.get("email"),
token=token,
)

View File

@ -3,6 +3,8 @@ import json
from fastapi.testclient import TestClient
from mealie.core.config import get_app_settings
from mealie.repos.repository_factory import AllRepositories
from mealie.services.user_services.user_service import UserService
from tests.utils.app_routes import AppRoutes
from tests.utils.fixture_schemas import TestUser
@ -35,3 +37,27 @@ def test_user_token_refresh(api_client: TestClient, api_routes: AppRoutes, admin
response = api_client.post(api_routes.auth_refresh, headers=admin_user.token)
response = api_client.get(api_routes.users_self, headers=admin_user.token)
assert response.status_code == 200
def test_user_lockout_after_bad_attemps(api_client: TestClient, unique_user: TestUser, database: AllRepositories):
"""
if the user has more than 5 bad login attemps the user will be locked out for 4 hours
This only applies if there is a user in the database with the same username
"""
routes = AppRoutes()
settings = get_app_settings()
for _ in range(settings.SECURITY_MAX_LOGIN_ATTEMPTS):
form_data = {"username": unique_user.email, "password": "bad_password"}
response = api_client.post(routes.auth_token, form_data)
assert response.status_code == 401
valid_data = {"username": unique_user.email, "password": unique_user.password}
response = api_client.post(routes.auth_token, valid_data)
assert response.status_code == 423
# Cleanup
user_service = UserService(database)
user = database.users.get_one(unique_user.user_id)
user_service.unlock_user(user)

View File

@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
ALEMBIC_VERSIONS = [
{"version_num": "f30cf048c228"},
{"version_num": "188374910655"},
]

View File

@ -0,0 +1,63 @@
from datetime import datetime, timedelta
from mealie.repos.repository_factory import AllRepositories
from mealie.services.user_services.user_service import UserService
from tests.utils.fixture_schemas import TestUser
def test_get_locked_users(database: AllRepositories, user_tuple: list[TestUser]) -> None:
usr_1, usr_2 = user_tuple
# Setup
user_service = UserService(database)
user_1 = database.users.get_one(usr_1.user_id)
user_2 = database.users.get_one(usr_2.user_id)
locked_users = user_service.get_locked_users()
assert len(locked_users) == 0
user_1 = user_service.lock_user(user_1)
locked_users = user_service.get_locked_users()
assert len(locked_users) == 1
assert locked_users[0].id == user_1.id
user_2 = user_service.lock_user(user_2)
locked_users = user_service.get_locked_users()
assert len(locked_users) == 2
for locked_user in locked_users:
if locked_user.id == user_1.id:
assert locked_user.locked_at == user_1.locked_at
elif locked_user.id == user_2.id:
assert locked_user.locked_at == user_2.locked_at
else:
assert False
# Cleanup
user_service.unlock_user(user_1)
user_service.unlock_user(user_2)
def test_lock_unlocker_user(database: AllRepositories, unique_user: TestUser) -> None:
user_service = UserService(database)
# Test that the user is unlocked
user = database.users.get_one(unique_user.user_id)
assert not user.locked_at
# Test that the user is locked
locked_user = user_service.lock_user(user)
assert locked_user.locked_at
assert locked_user.is_locked
unlocked_user = user_service.unlock_user(locked_user)
assert not unlocked_user.locked_at
assert not unlocked_user.is_locked
# Sanity check that the is_locked property is working
user.locked_at = datetime.now() - timedelta(days=2)
assert not user.is_locked

View File

@ -8,6 +8,7 @@ class TestUser:
email: str
user_id: UUID
username: str
password: str
_group_id: UUID
token: Any