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 generate_typescript_defs(path_as_module, str(out_path), exclude=("MealieModel")) # type: ignore
except Exception as e: except Exception as e:
failed_modules.append(module) 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: 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: 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: for f in failed_modules:
log.error("", f.name) # noqa log.error(f"{f.name}")
if __name__ == "__main__": if __name__ == "__main__":
log.info("\n-- Starting Global Components Generator --") # noqa log.info("\n-- Starting Global Components Generator --")
generate_global_components_types() generate_global_components_types()
log.info("\n-- Starting Pydantic To Typescript Generator --") # noqa log.info("\n-- Starting Pydantic To Typescript Generator --")
generate_typescript_types() generate_typescript_types()

View File

@ -18,7 +18,12 @@
| ALLOW_SIGNUP | true | Allow user sign-up without token (should match frontend env) | | 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 ### Database

View File

@ -1,14 +1,19 @@
import { BaseCRUDAPI } from "../_base"; 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 prefix = "/api";
const routes = { const routes = {
adminUsers: `${prefix}/admin/users`, adminUsers: `${prefix}/admin/users`,
adminUsersId: (tag: string) => `${prefix}/admin/users/${tag}`, 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> { export class AdminUsersApi extends BaseCRUDAPI<UserIn, UserOut, UserOut> {
baseRoute: string = routes.adminUsers; baseRoute: string = routes.adminUsers;
itemRoute = routes.adminUsersId; 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> <BaseCardSectionTitle title="User Management"> </BaseCardSectionTitle>
<section> <section>
<v-toolbar color="background" flat class="justify-between"> <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") }} {{ $t("general.create") }}
</BaseButton> </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-toolbar>
<v-data-table <v-data-table
:headers="headers" :headers="headers"
@ -53,14 +66,15 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext, useRouter } from "@nuxtjs/composition-api"; 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 { useUser, useAllUsers } from "~/composables/use-user";
import { UserOut } from "~/types/api-types/user"; import { UserOut } from "~/types/api-types/user";
export default defineComponent({ export default defineComponent({
layout: "admin", layout: "admin",
setup() { setup() {
const api = useUserApi(); const api = useAdminApi();
const refUserDialog = ref(); const refUserDialog = ref();
const { i18n } = useContext(); const { i18n } = useContext();
@ -97,9 +111,20 @@ export default defineComponent({
{ text: i18n.t("general.delete"), value: "actions", sortable: false, align: "center" }, { 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 { return {
unlockAllUsers,
...toRefs(state), ...toRefs(state),
api,
headers, headers,
deleteUser, deleteUser,
loading, loading,

View File

@ -160,6 +160,9 @@ export default defineComponent({
// @ts-ignore- see above // @ts-ignore- see above
if (error.response?.status === 401) { if (error.response?.status === 401) {
alert.error("Invalid Credentials"); alert.error("Invalid Credentials");
// @ts-ignore - see above
} else if (error.response?.status === 423) {
alert.error("Account Locked. Please try again later");
} else { } else {
alert.error("Something Went Wrong!"); alert.error("Something Went Wrong!");
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -107,6 +107,8 @@ export interface PrivateUser {
tokens?: LongLiveTokenOut[]; tokens?: LongLiveTokenOut[];
cacheKey: string; cacheKey: string;
password: string; password: string;
loginAttemps?: number;
lockedAt?: string;
} }
export interface PrivatePasswordResetToken { export interface PrivatePasswordResetToken {
userId: string; userId: string;
@ -134,6 +136,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[]; recipeIngredient?: RecipeIngredient[];
dateAdded?: string; dateAdded?: string;
dateUpdated?: string; dateUpdated?: string;
createdAt?: string;
updateAt?: string;
} }
export interface RecipeCategory { export interface RecipeCategory {
id?: string; id?: string;
@ -168,6 +172,8 @@ export interface IngredientUnit {
abbreviation?: string; abbreviation?: string;
useAbbreviation?: boolean; useAbbreviation?: boolean;
id: string; id: string;
createdAt?: string;
updateAt?: string;
} }
export interface CreateIngredientUnit { export interface CreateIngredientUnit {
name: string; name: string;
@ -182,6 +188,8 @@ export interface IngredientFood {
labelId?: string; labelId?: string;
id: string; id: string;
label?: MultiPurposeLabelSummary; label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
} }
export interface MultiPurposeLabelSummary { export interface MultiPurposeLabelSummary {
name: string; name: string;
@ -212,6 +220,9 @@ export interface TokenData {
user_id?: string; user_id?: string;
username?: string; username?: string;
} }
export interface UnlockResults {
unlocked?: number;
}
export interface UpdateGroup { export interface UpdateGroup {
name: string; name: string;
id: 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.routes.media import media_router
from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks
logger = get_logger()
settings = get_app_settings() settings = get_app_settings()
description = f""" description = f"""
@ -61,6 +60,10 @@ async def start_scheduler():
tasks.post_group_webhooks, tasks.post_group_webhooks,
) )
SchedulerRegistry.register_hourly(
tasks.locked_user_reset,
)
SchedulerRegistry.print_jobs() SchedulerRegistry.print_jobs()
await SchedulerService.start() await SchedulerService.start()
@ -77,6 +80,8 @@ api_routers()
@app.on_event("startup") @app.on_event("startup")
async def system_startup(): async def system_startup():
logger = get_logger()
await start_scheduler() await start_scheduler()
logger.info("-----SYSTEM STARTUP----- \n") 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.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.user import PrivateUser from mealie.schema.user import PrivateUser
from mealie.services.user_services.user_service import UserService
ALGORITHM = "HS256" ALGORITHM = "HS256"
class UserLockedOut(Exception):
...
def create_access_token(data: dict, expires_delta: timedelta = None) -> str: def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
settings = get_app_settings() 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)) 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 """Given a username and password, tries to authenticate by BINDing to an
LDAP server 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) user = db.users.get_one(email, "username", any_case=True)
if settings.LDAP_AUTH_ENABLED and (not user or user.password == "LDAP"): 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: if not user:
# To prevent user enumeration we perform the verify_password computation to ensure # 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") verify_password("abc123cba321", "$2b$12$JdHtJOlkPFwyxdjdygEzPOtYmdQF5/R5tHxw5Tq8pxjubyLqdIX5i")
return False return False
if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS or user.is_locked:
raise UserLockedOut()
elif not verify_password(password, user.password): 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 False
return user return user

View File

@ -36,6 +36,12 @@ class AppSettings(BaseSettings):
ALLOW_SIGNUP: bool = True ALLOW_SIGNUP: bool = True
# ===============================================
# Security Configuration
SECURITY_MAX_LOGIN_ATTEMPTS: int = 5
SECURITY_USER_LOCKOUT_TIME: int = 24 # Time in Hours
@property @property
def DOCS_URL(self) -> str | None: def DOCS_URL(self) -> str | None:
return "/docs" if self.API_DOCS else None return "/docs" if self.API_DOCS else None

View File

@ -1,4 +1,5 @@
from collections.abc import Generator from collections.abc import Generator
from contextlib import contextmanager
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker 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 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: def create_session() -> Session:
global SessionLocal global SessionLocal
return 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.core.config import get_app_settings
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@ -36,6 +36,8 @@ class User(SqlAlchemyBase, BaseMixins):
group = orm.relationship("Group", back_populates="users") group = orm.relationship("Group", back_populates="users")
cache_key = Column(String, default="1234") cache_key = Column(String, default="1234")
login_attemps = Column(Integer, default=0)
locked_at = Column(DateTime, default=None)
# Group Permissions # Group Permissions
can_manage = Column(Boolean, default=False) can_manage = Column(Boolean, default=False)

View File

@ -1,6 +1,5 @@
import random import random
import shutil import shutil
from typing import Optional
from pydantic import UUID4 from pydantic import UUID4
@ -38,8 +37,10 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
shutil.rmtree(PrivateUser.get_directory(value)) shutil.rmtree(PrivateUser.get_directory(value))
return entry # type: ignore 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() dbuser = self.session.query(User).filter(User.username == username).one_or_none()
if dbuser is None: return None if dbuser is None else self.schema.from_orm(dbuser)
return None
return self.schema.from_orm(dbuser) # type: ignore 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.routes._base.mixins import HttpRepo
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.responses import ErrorResponse 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.schema.user.user import UserIn, UserOut, UserPagination
from mealie.services.user_services.user_service import UserService
router = APIRouter(prefix="/users", tags=["Admin: Users"]) router = APIRouter(prefix="/users", tags=["Admin: Users"])
@ -17,9 +19,6 @@ router = APIRouter(prefix="/users", tags=["Admin: Users"])
class AdminUserManagementRoutes(BaseAdminController): class AdminUserManagementRoutes(BaseAdminController):
@cached_property @cached_property
def repo(self): def repo(self):
if not self.user:
raise Exception("No user is logged in.")
return self.repos.users return self.repos.users
# ======================================================================= # =======================================================================
@ -44,6 +43,13 @@ class AdminUserManagementRoutes(BaseAdminController):
data.password = security.hash_password(data.password) data.password = security.hash_password(data.password)
return self.mixins.create_one(data) 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) @router.get("/{item_id}", response_model=UserOut)
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id) 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 import security
from mealie.core.dependencies import get_current_user from mealie.core.dependencies import get_current_user
from mealie.core.security import authenticate_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.db.db_setup import generate_session
from mealie.routes._base.routers import UserAPIRouter from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.user import PrivateUser from mealie.schema.user import PrivateUser
@ -53,7 +54,10 @@ def get_token(data: CustomOAuth2Form = Depends(), session: Session = Depends(gen
email = data.username email = data.username
password = data.password password = data.password
try:
user = authenticate_user(session, email, password) # type: ignore 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: if not user:
raise HTTPException( raise HTTPException(

View File

@ -3,6 +3,8 @@ from typing import Optional
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
from pydantic.types import constr from pydantic.types import constr
from mealie.schema._mealie.mealie_model import MealieModel
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
@ -12,3 +14,7 @@ class Token(BaseModel):
class TokenData(BaseModel): class TokenData(BaseModel):
user_id: Optional[UUID4] user_id: Optional[UUID4]
username: Optional[constr(to_lower=True, strip_whitespace=True)] = None # type: ignore 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 pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID from uuid import UUID
from pydantic import UUID4 from pydantic import UUID4, validator
from pydantic.types import constr from pydantic.types import constr
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
@ -137,16 +138,30 @@ class UserFavorites(UserBase):
class PrivateUser(UserOut): class PrivateUser(UserOut):
password: str password: str
group_id: UUID4 group_id: UUID4
login_attemps: int = 0
locked_at: datetime | None = None
class Config: class Config:
orm_mode = True orm_mode = True
@validator("login_attemps", pre=True)
def none_to_zero(cls, v):
return 0 if v is None else v
@staticmethod @staticmethod
def get_directory(user_id: UUID4 | str) -> Path: def get_directory(user_id: UUID4 | str) -> Path:
user_dir = get_app_dirs().USER_DIR / str(user_id) user_dir = get_app_dirs().USER_DIR / str(user_id)
user_dir.mkdir(parents=True, exist_ok=True) user_dir.mkdir(parents=True, exist_ok=True)
return user_dir 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: def directory(self) -> Path:
return PrivateUser.get_directory(self.id) return PrivateUser.get_directory(self.id)
@ -168,15 +183,15 @@ class GroupInDB(UpdateGroup):
@staticmethod @staticmethod
def get_directory(id: UUID4) -> Path: def get_directory(id: UUID4) -> Path:
dir = get_app_dirs().GROUPS_DIR / str(id) group_dir = get_app_dirs().GROUPS_DIR / str(id)
dir.mkdir(parents=True, exist_ok=True) group_dir.mkdir(parents=True, exist_ok=True)
return dir return group_dir
@staticmethod @staticmethod
def get_export_directory(id: UUID) -> Path: def get_export_directory(id: UUID) -> Path:
dir = GroupInDB.get_directory(id) / "export" export_dir = GroupInDB.get_directory(id) / "export"
dir.mkdir(parents=True, exist_ok=True) export_dir.mkdir(parents=True, exist_ok=True)
return dir return export_dir
@property @property
def directory(self) -> Path: 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 engine: base.Engine
meta: MetaData 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_date = {"date_added", "date"}
look_for_time = {"scheduled_time"} 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_group_exports import purge_group_data_exports
from .purge_password_reset import purge_password_reset_tokens from .purge_password_reset import purge_password_reset_tokens
from .purge_registration import purge_group_registration from .purge_registration import purge_group_registration
from .reset_locked_users import locked_user_reset
__all__ = [ __all__ = [
"post_group_webhooks", "post_group_webhooks",
"purge_password_reset_tokens", "purge_password_reset_tokens",
"purge_group_data_exports", "purge_group_data_exports",
"purge_group_registration", "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 fastapi import HTTPException, status
from sqlalchemy.orm.session import Session 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.core.security import hash_password, url_safe_token
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.schema.user.user_passwords import SavePasswordResetToken from mealie.schema.user.user_passwords import SavePasswordResetToken
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
from mealie.services.email import EmailService from mealie.services.email import EmailService
logger = get_logger(__name__)
class PasswordResetService(BaseService): class PasswordResetService(BaseService):
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
@ -20,7 +17,7 @@ class PasswordResetService(BaseService):
user = self.db.users.get_one(email, "email", any_case=True) user = self.db.users.get_one(email, "email", any_case=True)
if user is None: 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 # Do not raise exception here as we don't want to confirm to the client that the Email doens't exists
return None return None
@ -41,7 +38,7 @@ class PasswordResetService(BaseService):
try: try:
email_servive.send_forgot_password(email, reset_url) email_servive.send_forgot_password(email, reset_url)
except Exception as e: 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") raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to send reset email")
def reset_password(self, token: str, new_password: str): 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") token_entry = self.db.tokens_pw_reset.get_one(token, "token")
if token_entry is None: 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") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid token")
user = self.db.users.get_one(token_entry.user_id) 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) new_user = self.db.users.update_password(user.id, password_hash)
# Confirm Password # Confirm Password
if new_user.password != password_hash: 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") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid password")
# Delete Token from DB # 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( yield utils.TestUser(
_group_id=user_data.get("groupId"), _group_id=user_data.get("groupId"),
user_id=user_data.get("id"), user_id=user_data.get("id"),
password=settings.DEFAULT_PASSWORD,
username=user_data.get("username"), username=user_data.get("username"),
email=user_data.get("email"), email=user_data.get("email"),
token=token, 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"), _group_id=user_data.get("groupId"),
user_id=user_data.get("id"), user_id=user_data.get("id"),
email=user_data.get("email"), email=user_data.get("email"),
password=registration.password,
username=user_data.get("username"), username=user_data.get("username"),
token=token, token=token,
) )
@ -67,6 +68,7 @@ def g2_user(admin_token, api_client: TestClient, api_routes: utils.AppRoutes):
user_id=user_id, user_id=user_id,
_group_id=group_id, _group_id=group_id,
token=token, token=token,
password="useruser",
email=create_data["email"], email=create_data["email"],
username=create_data.get("username"), username=create_data.get("username"),
) )
@ -92,6 +94,7 @@ def unique_user(api_client: TestClient, api_routes: utils.AppRoutes):
yield utils.TestUser( yield utils.TestUser(
_group_id=user_data.get("groupId"), _group_id=user_data.get("groupId"),
user_id=user_data.get("id"), user_id=user_data.get("id"),
password=registration.password,
email=user_data.get("email"), email=user_data.get("email"),
username=user_data.get("username"), username=user_data.get("username"),
token=token, token=token,
@ -144,6 +147,7 @@ def user_tuple(admin_token, api_client: TestClient, api_routes: utils.AppRoutes)
_group_id=user_data.get("groupId"), _group_id=user_data.get("groupId"),
user_id=user_data.get("id"), user_id=user_data.get("id"),
username=user_data.get("username"), username=user_data.get("username"),
password="useruser",
email=user_data.get("email"), email=user_data.get("email"),
token=token, token=token,
) )

View File

@ -3,6 +3,8 @@ import json
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mealie.core.config import get_app_settings 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.app_routes import AppRoutes
from tests.utils.fixture_schemas import TestUser 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.post(api_routes.auth_refresh, headers=admin_user.token)
response = api_client.get(api_routes.users_self, headers=admin_user.token) response = api_client.get(api_routes.users_self, headers=admin_user.token)
assert response.status_code == 200 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 from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
ALEMBIC_VERSIONS = [ 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 email: str
user_id: UUID user_id: UUID
username: str username: str
password: str
_group_id: UUID _group_id: UUID
token: Any token: Any