Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
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
  • Loading branch information
hay-kot committed Aug 13, 2022
1 parent ca64584 commit b3c41a4
Show file tree
Hide file tree
Showing 35 changed files with 450 additions and 46 deletions.
@@ -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")
18 changes: 9 additions & 9 deletions dev/code-generation/gen_frontend_types.py
Expand Up @@ -99,24 +99,24 @@ def path_to_module(path: Path):
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()
Expand Up @@ -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

Expand All @@ -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' |
Expand Down
7 changes: 6 additions & 1 deletion frontend/api/admin/admin-users.ts
@@ -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), {});
}
}
33 changes: 29 additions & 4 deletions frontend/pages/admin/manage/users/index.vue
Expand Up @@ -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"
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion frontend/pages/login.vue
Expand Up @@ -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!");
}
Expand Down
6 changes: 6 additions & 0 deletions frontend/types/api-types/admin.ts
Expand Up @@ -101,6 +101,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCategory {
id?: string;
Expand Down Expand Up @@ -135,6 +137,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
Expand All @@ -149,6 +153,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
Expand Down
6 changes: 6 additions & 0 deletions frontend/types/api-types/cookbook.ts
Expand Up @@ -86,6 +86,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCategory {
id?: string;
Expand Down Expand Up @@ -114,6 +116,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
Expand All @@ -128,6 +132,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
Expand Down
24 changes: 23 additions & 1 deletion frontend/types/api-types/group.ts
Expand Up @@ -197,6 +197,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
Expand All @@ -211,6 +213,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface ReadGroupPreferences {
privateGroup?: boolean;
Expand Down Expand Up @@ -259,6 +263,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCategory {
id?: string;
Expand Down Expand Up @@ -322,6 +328,8 @@ export interface SetPermissions {
}
export interface ShoppingListCreate {
name?: string;
createdAt?: string;
updateAt?: string;
}
export interface ShoppingListItemCreate {
shoppingListId: string;
Expand All @@ -336,6 +344,8 @@ export interface ShoppingListItemCreate {
food?: IngredientFood;
labelId?: string;
recipeReferences?: ShoppingListItemRecipeRef[];
createdAt?: string;
updateAt?: string;
}
export interface ShoppingListItemRecipeRef {
recipeId: string;
Expand All @@ -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;
}
Expand All @@ -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[];
Expand All @@ -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[];
Expand Down
6 changes: 6 additions & 0 deletions frontend/types/api-types/meal-plan.ts
Expand Up @@ -116,6 +116,8 @@ export interface RecipeSummary {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
}
export interface RecipeCategory {
id?: string;
Expand Down Expand Up @@ -150,6 +152,8 @@ export interface IngredientUnit {
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string;
updateAt?: string;
}
export interface CreateIngredientUnit {
name: string;
Expand All @@ -164,6 +168,8 @@ export interface IngredientFood {
labelId?: string;
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface MultiPurposeLabelSummary {
name: string;
Expand Down

0 comments on commit b3c41a4

Please sign in to comment.