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)[];
keys.forEach((key) => {
if (key in validators) {
// @ts-ignore TODO: fix this
list.push(validators[key]);
const split = key.split(":");
const validatorKey = split[0] as ValidatorKey;
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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -58,6 +58,23 @@ class UserController(BaseUserController):
def get_logged_in_user(self):
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}")
def update_user(self, item_id: UUID4, new_data: UserBase):
assert_user_change_allowed(item_id, self.user)
@ -83,20 +100,3 @@ class UserController(BaseUserController):
) from e
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 uuid import UUID
from pydantic import UUID4, validator
from pydantic import UUID4, Field, validator
from pydantic.types import constr
from pydantic.utils import GetterDict
@ -49,7 +49,7 @@ class DeleteTokenResponse(MealieModel):
class ChangePassword(MealieModel):
current_password: str
new_password: str
new_password: str = Field(..., min_length=8)
class GroupBase(MealieModel):