1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-25 08:09:41 +02:00

security: enforce min length for user password (#1555)

* fix typing on auth context

* extract user password strength meter

* fix broken useToggle method

* extend form to accept arguments for validators

* enforce password length on update

* fix user password change form
This commit is contained in:
Hayden 2022-08-13 21:38:26 -08:00 committed by GitHub
parent b3c41a4bd0
commit 54c4f19a5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 105 additions and 95 deletions

View file

@ -0,0 +1,38 @@
<template>
<div class="d-flex justify-center pb-6 mt-n1">
<div style="flex-basis: 500px">
<strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong>
<v-progress-linear
:value="pwStrength.score.value"
class="rounded-lg"
:color="pwStrength.color.value"
height="15"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, toRef } from "@nuxtjs/composition-api";
import { usePasswordStrength } from "~/composables/use-passwords";
export default defineComponent({
props: {
value: {
type: String,
default: "",
},
},
setup(props) {
const asRef = toRef(props, "value");
const pwStrength = usePasswordStrength(asRef);
return {
pwStrength,
};
},
});
</script>
<style scoped></style>

View file

@ -187,9 +187,16 @@ export default defineComponent({
const list = [] as ((v: string) => boolean | string)[]; const list = [] as ((v: string) => boolean | string)[];
keys.forEach((key) => { keys.forEach((key) => {
if (key in validators) { const split = key.split(":");
// @ts-ignore TODO: fix this const validatorKey = split[0] as ValidatorKey;
list.push(validators[key]); if (validatorKey in validators) {
if (split.length === 1) {
// @ts-ignore- validators[validatorKey] is a function
list.push(validators[validatorKey]);
} else {
// @ts-ignore - validators[validatorKey] is a function
list.push(validators[validatorKey](split[1]));
}
} }
}); });
return list; return list;

View file

@ -6,8 +6,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, watch } from "@nuxtjs/composition-api"; import { defineComponent, ref, watch } from "@nuxtjs/composition-api";
import { useToggle } from "@vueuse/core";
export default defineComponent({ export default defineComponent({
props: { props: {
@ -21,7 +20,11 @@ export default defineComponent({
}, },
}, },
setup(_, context) { setup(_, context) {
const [state, toggle] = useToggle(); const state = ref(false);
const toggle = () => {
state.value = !state.value;
};
watch(state, () => { watch(state, () => {
context.emit("input", state); context.emit("input", state);

View file

@ -27,7 +27,7 @@ export const useUserForm = () => {
varName: "password", varName: "password",
disableUpdate: true, disableUpdate: true,
type: fieldTypes.PASSWORD, type: fieldTypes.PASSWORD,
rules: ["required"], rules: ["required", "minLength:8"],
}, },
{ {
section: "Permissions", section: "Permissions",

View file

@ -172,17 +172,9 @@
:rules="[validators.required, validators.minLength(8), validators.maxLength(258)]" :rules="[validators.required, validators.minLength(8), validators.maxLength(258)]"
@click:append="pwFields.togglePasswordShow" @click:append="pwFields.togglePasswordShow"
/> />
<div class="d-flex justify-center pb-6 mt-n1">
<div style="flex-basis: 500px"> <UserPasswordStrength :value="credentials.password1.value" />
<strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong>
<v-progress-linear
:value="pwStrength.score.value"
class="rounded-lg"
:color="pwStrength.color.value"
height="15"
/>
</div>
</div>
<v-text-field <v-text-field
v-model="credentials.password2.value" v-model="credentials.password2.value"
v-bind="inputAttrs" v-bind="inputAttrs"
@ -272,9 +264,10 @@ import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { CreateUserRegistration } from "~/types/api-types/user"; import { CreateUserRegistration } from "~/types/api-types/user";
import { VForm } from "~/types/vuetify"; import { VForm } from "~/types/vuetify";
import { usePasswordField, usePasswordStrength } from "~/composables/use-passwords"; import { usePasswordField } from "~/composables/use-passwords";
import { usePublicApi } from "~/composables/api/api-client"; import { usePublicApi } from "~/composables/api/api-client";
import { useLocales } from "~/composables/use-locales"; import { useLocales } from "~/composables/use-locales";
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
const inputAttrs = { const inputAttrs = {
filled: true, filled: true,
@ -284,59 +277,49 @@ const inputAttrs = {
}; };
export default defineComponent({ export default defineComponent({
components: { UserPasswordStrength },
layout: "blank", layout: "blank",
setup() { setup() {
const { i18n } = useContext(); const { i18n } = useContext();
const isDark = useDark(); const isDark = useDark();
function safeValidate(form: Ref<VForm | null>) { function safeValidate(form: Ref<VForm | null>) {
if (form.value && form.value.validate) { if (form.value && form.value.validate) {
return form.value.validate(); return form.value.validate();
} }
return false; return false;
} }
// ================================================================ // ================================================================
// Registration Context // Registration Context
// //
// State is used to manage the registration process states and provide // State is used to manage the registration process states and provide
// a state machine esq interface to interact with the registration workflow. // a state machine esq interface to interact with the registration workflow.
const state = useRegistration(); const state = useRegistration();
// ================================================================ // ================================================================
// Handle Token URL / Initialization // Handle Token URL / Initialization
// //
const token = useRouteQuery("token"); const token = useRouteQuery("token");
// TODO: We need to have some way to check to see if the site is in a state // TODO: We need to have some way to check to see if the site is in a state
// Where it needs to be initialized with a user, in that case we'll handle that // Where it needs to be initialized with a user, in that case we'll handle that
// somewhere... // somewhere...
function initialUser() { function initialUser() {
return false; return false;
} }
onMounted(() => { onMounted(() => {
if (token.value) { if (token.value) {
state.setState(States.ProvideAccountDetails); state.setState(States.ProvideAccountDetails);
state.setType(RegistrationType.JoinGroup); state.setType(RegistrationType.JoinGroup);
} }
if (initialUser()) { if (initialUser()) {
state.setState(States.ProvideGroupDetails); state.setState(States.ProvideGroupDetails);
state.setType(RegistrationType.InitialGroup); state.setType(RegistrationType.InitialGroup);
} }
}); });
// ================================================================ // ================================================================
// Initial // Initial
const initial = { const initial = {
createGroup: () => { createGroup: () => {
state.setState(States.ProvideGroupDetails); state.setState(States.ProvideGroupDetails);
state.setType(RegistrationType.CreateGroup); state.setType(RegistrationType.CreateGroup);
if (token.value != null) { if (token.value != null) {
token.value = null; token.value = null;
} }
@ -346,47 +329,36 @@ export default defineComponent({
state.setType(RegistrationType.JoinGroup); state.setType(RegistrationType.JoinGroup);
}, },
}; };
// ================================================================ // ================================================================
// Provide Token // Provide Token
const domTokenForm = ref<VForm | null>(null); const domTokenForm = ref<VForm | null>(null);
function validateToken() { function validateToken() {
return true; return true;
} }
const provideToken = { const provideToken = {
next: () => { next: () => {
if (!safeValidate(domTokenForm as Ref<VForm>)) { if (!safeValidate(domTokenForm as Ref<VForm>)) {
return; return;
} }
if (validateToken()) { if (validateToken()) {
state.setState(States.ProvideAccountDetails); state.setState(States.ProvideAccountDetails);
} }
}, },
}; };
// ================================================================ // ================================================================
// Provide Group Details // Provide Group Details
const publicApi = usePublicApi(); const publicApi = usePublicApi();
const domGroupForm = ref<VForm | null>(null); const domGroupForm = ref<VForm | null>(null);
const groupName = ref(""); const groupName = ref("");
const groupSeed = ref(false); const groupSeed = ref(false);
const groupPrivate = ref(false); const groupPrivate = ref(false);
const groupErrorMessages = ref<string[]>([]); const groupErrorMessages = ref<string[]>([]);
const { validate: validGroupName, valid: groupNameValid } = useAsyncValidator( const { validate: validGroupName, valid: groupNameValid } = useAsyncValidator(
groupName, groupName,
(v: string) => publicApi.validators.group(v), (v: string) => publicApi.validators.group(v),
i18n.tc("validation.group-name-is-taken"), i18n.tc("validation.group-name-is-taken"),
groupErrorMessages groupErrorMessages
); );
const groupDetails = { const groupDetails = {
groupName, groupName,
groupSeed, groupSeed,
@ -395,28 +367,22 @@ export default defineComponent({
if (!safeValidate(domGroupForm as Ref<VForm>) || !groupNameValid.value) { if (!safeValidate(domGroupForm as Ref<VForm>) || !groupNameValid.value) {
return; return;
} }
state.setState(States.ProvideAccountDetails); state.setState(States.ProvideAccountDetails);
}, },
}; };
// ================================================================ // ================================================================
// Provide Account Details // Provide Account Details
const domAccountForm = ref<VForm | null>(null); const domAccountForm = ref<VForm | null>(null);
const username = ref(""); const username = ref("");
const email = ref(""); const email = ref("");
const advancedOptions = ref(false); const advancedOptions = ref(false);
const usernameErrorMessages = ref<string[]>([]); const usernameErrorMessages = ref<string[]>([]);
const { validate: validateUsername, valid: validUsername } = useAsyncValidator( const { validate: validateUsername, valid: validUsername } = useAsyncValidator(
username, username,
(v: string) => publicApi.validators.username(v), (v: string) => publicApi.validators.username(v),
i18n.tc("validation.username-is-taken"), i18n.tc("validation.username-is-taken"),
usernameErrorMessages usernameErrorMessages
); );
const emailErrorMessages = ref<string[]>([]); const emailErrorMessages = ref<string[]>([]);
const { validate: validateEmail, valid: validEmail } = useAsyncValidator( const { validate: validateEmail, valid: validEmail } = useAsyncValidator(
email, email,
@ -424,7 +390,6 @@ export default defineComponent({
i18n.tc("validation.email-is-taken"), i18n.tc("validation.email-is-taken"),
emailErrorMessages emailErrorMessages
); );
const accountDetails = { const accountDetails = {
username, username,
email, email,
@ -433,37 +398,26 @@ export default defineComponent({
if (!safeValidate(domAccountForm as Ref<VForm>) || !validUsername.value || !validEmail.value) { if (!safeValidate(domAccountForm as Ref<VForm>) || !validUsername.value || !validEmail.value) {
return; return;
} }
state.setState(States.Confirmation); state.setState(States.Confirmation);
}, },
}; };
// ================================================================ // ================================================================
// Provide Credentials // Provide Credentials
const password1 = ref(""); const password1 = ref("");
const password2 = ref(""); const password2 = ref("");
const pwStrength = usePasswordStrength(password1);
const pwFields = usePasswordField(); const pwFields = usePasswordField();
const passwordMatch = () => password1.value === password2.value || i18n.tc("user.password-must-match"); const passwordMatch = () => password1.value === password2.value || i18n.tc("user.password-must-match");
const credentials = { const credentials = {
password1, password1,
password2, password2,
passwordMatch, passwordMatch,
}; };
// ================================================================ // ================================================================
// Locale // Locale
const { locale } = useLocales(); const { locale } = useLocales();
const langDialog = ref(false); const langDialog = ref(false);
// ================================================================ // ================================================================
// Confirmation // Confirmation
const confirmationData = computed(() => { const confirmationData = computed(() => {
return [ return [
{ {
@ -498,10 +452,8 @@ export default defineComponent({
}, },
]; ];
}); });
const api = useUserApi(); const api = useUserApi();
const router = useRouter(); const router = useRouter();
async function submitRegistration() { async function submitRegistration() {
const payload: CreateUserRegistration = { const payload: CreateUserRegistration = {
email: email.value, email: email.value,
@ -511,7 +463,6 @@ export default defineComponent({
locale: locale.value, locale: locale.value,
seedData: groupSeed.value, seedData: groupSeed.value,
}; };
if (state.ctx.type === RegistrationType.CreateGroup) { if (state.ctx.type === RegistrationType.CreateGroup) {
payload.group = groupName.value; payload.group = groupName.value;
payload.advanced = advancedOptions.value; payload.advanced = advancedOptions.value;
@ -519,15 +470,12 @@ export default defineComponent({
} else { } else {
payload.groupToken = token.value; payload.groupToken = token.value;
} }
const { response } = await api.register.register(payload); const { response } = await api.register.register(payload);
if (response?.status === 201) { if (response?.status === 201) {
alert.success("Registration Success"); alert.success("Registration Success");
router.push("/login"); router.push("/login");
} }
} }
return { return {
accountDetails, accountDetails,
confirmationData, confirmationData,
@ -541,7 +489,6 @@ export default defineComponent({
langDialog, langDialog,
provideToken, provideToken,
pwFields, pwFields,
pwStrength,
RegistrationType, RegistrationType,
state, state,
States, States,
@ -549,12 +496,10 @@ export default defineComponent({
usernameErrorMessages, usernameErrorMessages,
validators, validators,
submitRegistration, submitRegistration,
// Validators // Validators
validGroupName, validGroupName,
validateUsername, validateUsername,
validateEmail, validateEmail,
// Dom Refs // Dom Refs
domAccountForm, domAccountForm,
domGroupForm, domGroupForm,

View file

@ -49,7 +49,7 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</div> </div>
<div v-if="state" key="change-password"> <div v-else key="change-password">
<BaseCardSectionTitle class="mt-10" :title="$tc('settings.change-password')"> </BaseCardSectionTitle> <BaseCardSectionTitle class="mt-10" :title="$tc('settings.change-password')"> </BaseCardSectionTitle>
<v-card outlined> <v-card outlined>
<v-card-text class="pb-0"> <v-card-text class="pb-0">
@ -61,16 +61,18 @@
validate-on-blur validate-on-blur
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? $globals.icons.eye : $globals.icons.eyeOff" :append-icon="showPassword ? $globals.icons.eye : $globals.icons.eyeOff"
:rules="[validators.minLength(1)]"
@click:append="showPassword = !showPassword" @click:append="showPassword = !showPassword"
></v-text-field> />
<v-text-field <v-text-field
v-model="password.newOne" v-model="password.newOne"
:prepend-icon="$globals.icons.lock" :prepend-icon="$globals.icons.lock"
:label="$t('user.new-password')" :label="$t('user.new-password')"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? $globals.icons.eye : $globals.icons.eyeOff" :append-icon="showPassword ? $globals.icons.eye : $globals.icons.eyeOff"
:rules="[validators.minLength(8)]"
@click:append="showPassword = !showPassword" @click:append="showPassword = !showPassword"
></v-text-field> />
<v-text-field <v-text-field
v-model="password.newTwo" v-model="password.newTwo"
:prepend-icon="$globals.icons.lock" :prepend-icon="$globals.icons.lock"
@ -80,7 +82,8 @@
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? $globals.icons.eye : $globals.icons.eyeOff" :append-icon="showPassword ? $globals.icons.eye : $globals.icons.eyeOff"
@click:append="showPassword = !showPassword" @click:append="showPassword = !showPassword"
></v-text-field> />
<UserPasswordStrength :value="password.newOne" />
</v-form> </v-form>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
@ -124,14 +127,17 @@ import { useUserApi } from "~/composables/api";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { VForm } from "~/types/vuetify"; import { VForm } from "~/types/vuetify";
import { UserOut } from "~/types/api-types/user"; import { UserOut } from "~/types/api-types/user";
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
import { validators } from "~/composables/use-validators";
export default defineComponent({ export default defineComponent({
components: { components: {
UserAvatar, UserAvatar,
UserPasswordStrength,
}, },
setup() { setup() {
const nuxtContext = useContext(); const { $auth } = useContext();
const user = computed(() => nuxtContext.$auth.user as unknown as UserOut); const user = computed(() => $auth.user as unknown as UserOut);
watch(user, () => { watch(user, () => {
userCopy.value = { ...user.value }; userCopy.value = { ...user.value };
@ -153,7 +159,7 @@ export default defineComponent({
async function updateUser() { async function updateUser() {
const { response } = await api.users.updateOne(userCopy.value.id, userCopy.value); const { response } = await api.users.updateOne(userCopy.value.id, userCopy.value);
if (response?.status === 200) { if (response?.status === 200) {
nuxtContext.$auth.fetchUser(); $auth.fetchUser();
} }
} }
@ -178,7 +184,16 @@ export default defineComponent({
loading: false, loading: false,
}); });
return { ...toRefs(state), updateUser, updatePassword, userCopy, password, domUpdatePassword, passwordsMatch }; return {
...toRefs(state),
updateUser,
updatePassword,
userCopy,
password,
domUpdatePassword,
passwordsMatch,
validators,
};
}, },
head() { head() {
return { return {

View file

@ -1,4 +1,5 @@
import { Plugin } from "@nuxt/types"; import { Plugin } from "@nuxt/types";
import { Auth } from "@nuxtjs/auth-next";
import { Framework } from "vuetify"; import { Framework } from "vuetify";
import { icons } from "~/utils/icons"; import { icons } from "~/utils/icons";
import { Icon } from "~/utils/icons/icon-type"; import { Icon } from "~/utils/icons/icon-type";
@ -17,6 +18,7 @@ declare module "@nuxt/types" {
interface Context { interface Context {
$globals: Globals; $globals: Globals;
$vuetify: Framework; $vuetify: Framework;
$auth: Auth;
} }
} }

View file

@ -58,6 +58,23 @@ class UserController(BaseUserController):
def get_logged_in_user(self): def get_logged_in_user(self):
return self.user return self.user
@user_router.put("/password")
def update_password(self, password_change: ChangePassword):
"""Resets the User Password"""
if not verify_password(password_change.current_password, self.user.password):
raise HTTPException(status.HTTP_400_BAD_REQUEST, ErrorResponse.respond("Invalid current password"))
self.user.password = hash_password(password_change.new_password)
try:
self.repos.users.update_password(self.user.id, self.user.password)
except Exception as e:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
ErrorResponse.respond("Failed to update password"),
) from e
return SuccessResponse.respond("Password updated")
@user_router.put("/{item_id}") @user_router.put("/{item_id}")
def update_user(self, item_id: UUID4, new_data: UserBase): def update_user(self, item_id: UUID4, new_data: UserBase):
assert_user_change_allowed(item_id, self.user) assert_user_change_allowed(item_id, self.user)
@ -83,20 +100,3 @@ class UserController(BaseUserController):
) from e ) from e
return SuccessResponse.respond("User updated") return SuccessResponse.respond("User updated")
@user_router.put("/password")
def update_password(self, password_change: ChangePassword):
"""Resets the User Password"""
if not verify_password(password_change.current_password, self.user.password):
raise HTTPException(status.HTTP_400_BAD_REQUEST, ErrorResponse.respond("Invalid current password"))
self.user.password = hash_password(password_change.new_password)
try:
self.repos.users.update_password(self.user.id, self.user.password)
except Exception as e:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
ErrorResponse.respond("Failed to update password"),
) from e
return SuccessResponse.respond("Password updated")

View file

@ -3,7 +3,7 @@ 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, validator from pydantic import UUID4, Field, validator
from pydantic.types import constr from pydantic.types import constr
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
@ -49,7 +49,7 @@ class DeleteTokenResponse(MealieModel):
class ChangePassword(MealieModel): class ChangePassword(MealieModel):
current_password: str current_password: str
new_password: str new_password: str = Field(..., min_length=8)
class GroupBase(MealieModel): class GroupBase(MealieModel):