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:
parent
b3c41a4bd0
commit
54c4f19a5c
9 changed files with 105 additions and 95 deletions
38
frontend/components/Domain/User/UserPasswordStrength.vue
Normal file
38
frontend/components/Domain/User/UserPasswordStrength.vue
Normal 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>
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -27,7 +27,7 @@ export const useUserForm = () => {
|
|||
varName: "password",
|
||||
disableUpdate: true,
|
||||
type: fieldTypes.PASSWORD,
|
||||
rules: ["required"],
|
||||
rules: ["required", "minLength:8"],
|
||||
},
|
||||
{
|
||||
section: "Permissions",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in a new issue