Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions client-v3/src/components/user/CreateUser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
type="password"
:state="fieldState('password')"
/>
<BFormInvalidFeedback>Required, at least 6 characters.</BFormInvalidFeedback>
<BFormInvalidFeedback>Required, between 6 and 72 characters.</BFormInvalidFeedback>
</BFormGroup>

<BFormGroup label="Confirm Password" label-for="confirm-password-input" label-cols="4">
Expand All @@ -45,6 +45,7 @@
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, sameAs, helpers } from '@vuelidate/validators';
import { maxPasswordByteLength } from '@/js/customValidators';
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/user';

Expand Down Expand Up @@ -78,7 +79,7 @@ const passwordRef = computed(() => state.value.password);

const rules = {
username: { required, isUsernameUnique },
password: { required, minLength: minLength(6) },
password: { required, minLength: minLength(6), maxPasswordByteLength },
confirmPassword: { required, sameAs: sameAs(passwordRef) },
};

Expand Down
4 changes: 2 additions & 2 deletions client-v3/src/components/user/settings/ChangePassword.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
id="new-password-input-group"
label="New Password"
label-for="new-password-input"
description="Minimum 6 characters"
description="6–72 characters"
>
<BFormInput
id="new-password-input"
Expand All @@ -36,7 +36,7 @@
autocomplete="new-password"
/>
<BFormInvalidFeedback id="new-password-feedback">
This is a required field and must be at least 6 characters.
This is a required field and must be between 6 and 72 characters.
</BFormInvalidFeedback>
</BFormGroup>

Expand Down
3 changes: 2 additions & 1 deletion client-v3/src/composables/usePasswordValidation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { computed } from 'vue';
import { required, minLength, sameAs } from '@vuelidate/validators';
import { maxPasswordByteLength } from '@/js/customValidators';

export function usePasswordValidation() {
const passwordRules = { required, minLength: minLength(6) };
const passwordRules = { required, minLength: minLength(6), maxPasswordByteLength };

function confirmPasswordRules(getPasswordValue: () => string) {
return computed(() => ({ required, sameAsPassword: sameAs(getPasswordValue()) }));
Expand Down
2 changes: 2 additions & 0 deletions client-v3/src/js/customValidators.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const notNull = (value: unknown): boolean => value != null;
export const notNullAndGreaterThanZero = (value: unknown): boolean =>
value != null && (value as number) > 0;
export const maxPasswordByteLength = (value: unknown): boolean =>
typeof value !== 'string' || new TextEncoder().encode(value).length <= 72;
7 changes: 4 additions & 3 deletions client-v3/src/views/user/ForcePasswordChangeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
id="new-password-input-group"
label="New Password"
label-for="new-password-input"
description="Minimum 6 characters"
description="6–72 characters"
>
<BFormInput
id="new-password-input"
Expand All @@ -29,7 +29,7 @@
autocomplete="new-password"
/>
<BFormInvalidFeedback id="new-password-feedback">
This is a required field and must be at least 6 characters.
This is a required field and must be between 6 and 72 characters.
</BFormInvalidFeedback>
</BFormGroup>

Expand Down Expand Up @@ -75,6 +75,7 @@ import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, sameAs } from '@vuelidate/validators';
import { maxPasswordByteLength } from '@/js/customValidators';
import { useUserStore } from '@/stores/user';
import { useFormValidation } from '@/composables/useFormValidation';

Expand All @@ -86,7 +87,7 @@ const state = ref({ newPassword: '', confirmPassword: '' });
const loading = ref(false);

const rules = computed(() => ({
newPassword: { required, minLength: minLength(6) },
newPassword: { required, minLength: minLength(6), maxPasswordByteLength },
confirmPassword: { required, sameAsPassword: sameAs(state.value.newPassword) },
}));

Expand Down
2 changes: 2 additions & 0 deletions client/src/js/customValidators.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const notNull = (value: unknown): boolean => value != null;
export const notNullAndGreaterThanZero = (value: unknown): boolean =>
value != null && (value as number) > 0;
export const maxPasswordByteLength = (value: unknown): boolean =>
typeof value !== 'string' || new TextEncoder().encode(value).length <= 72;
2 changes: 2 additions & 0 deletions client/src/mixins/passwordValidation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineComponent } from 'vue';
import { required, minLength, sameAs } from 'vuelidate/lib/validators';
import { maxPasswordByteLength } from '@/js/customValidators';

export default defineComponent({
methods: {
Expand All @@ -12,6 +13,7 @@ export default defineComponent({
return {
required,
minLength: minLength(6),
maxPasswordByteLength,
};
},

Expand Down
4 changes: 2 additions & 2 deletions client/src/views/user/ForcePasswordChangeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
id="new-password-input-group"
label="New Password"
label-for="new-password-input"
description="Minimum 6 characters"
description="6–72 characters"
>
<b-form-input
id="new-password-input"
Expand All @@ -33,7 +33,7 @@
autocomplete="new-password"
/>
<b-form-invalid-feedback id="new-password-feedback">
This is a required field and must be at least 6 characters.
This is a required field and must be between 6 and 72 characters.
</b-form-invalid-feedback>
</b-form-group>

Expand Down
5 changes: 3 additions & 2 deletions client/src/vue_components/user/CreateUser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
type="password"
/>
<b-form-invalid-feedback id="password-feedback">
This is a required field and must be at least 6 characters.
This is a required field and must be between 6 and 72 characters.
</b-form-invalid-feedback>
</b-form-group>
<b-form-group
Expand Down Expand Up @@ -66,6 +66,7 @@
import { defineComponent } from 'vue';
import { required, minLength, sameAs } from 'vuelidate/lib/validators';
import { mapActions, mapGetters } from 'vuex';
import { maxPasswordByteLength } from '@/js/customValidators';

function isUsernameUnique(this: any, value: string): boolean {
if (value === '') return true;
Expand Down Expand Up @@ -102,7 +103,7 @@ export default defineComponent({
validations: {
state: {
username: { required, isUsernameUnique },
password: { required, minLength: minLength(6) },
password: { required, minLength: minLength(6), maxPasswordByteLength },
confirmPassword: { required, sameAsPassword: sameAs('password') },
},
},
Expand Down
4 changes: 2 additions & 2 deletions client/src/vue_components/user/settings/ChangePassword.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
id="new-password-input-group"
label="New Password"
label-for="new-password-input"
description="Minimum 6 characters"
description="6–72 characters"
>
<b-form-input
id="new-password-input"
Expand All @@ -36,7 +36,7 @@
autocomplete="new-password"
/>
<b-form-invalid-feedback id="new-password-feedback">
This is a required field and must be at least 6 characters.
This is a required field and must be between 6 and 72 characters.
</b-form-invalid-feedback>
</b-form-group>

Expand Down
7 changes: 3 additions & 4 deletions server/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
tornado==6.5.6
sqlalchemy>=2.0.50,<2.1.0
datetime==4.9
python-dateutil==2.9.0.post0
marshmallow-sqlalchemy>=1.5.0
tornado-prometheus==0.1.2
bcrypt==4.3.0
bcrypt==5.0.0
anytree==2.13.0
alembic==1.18.4
marshmallow<5
marshmallow>=4.3.0,<5
pyjwt[crypto]==2.13.0
setuptools==80.10.2
setuptools==82.0.1
xkcdpass==1.30.0
zeroconf==0.149.16
python-jsonpath==2.0.2
4 changes: 4 additions & 0 deletions server/services/password_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def validate_password_strength(password: str) -> tuple[bool, str]:

Current requirements:
- Minimum 6 characters
- Maximum 72 bytes when UTF-8 encoded (bcrypt hard limit)

:param password: Password to validate
:type password: str
Expand All @@ -61,6 +62,9 @@ def validate_password_strength(password: str) -> tuple[bool, str]:
if len(password) < 6:
return False, "Password must be at least 6 characters long"

if len(password.encode("utf-8")) > 72:
return False, "Password must be 72 characters or fewer"

return True, ""

@staticmethod
Expand Down
24 changes: 24 additions & 0 deletions server/test/services/test_password_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,30 @@ def test_validate_password_strength_too_short(self):
self.assertFalse(is_valid)
self.assertEqual("Password must be at least 6 characters long", error_msg)

def test_validate_password_strength_at_72_byte_limit(self):
"""Test that a password exactly 72 bytes long is accepted"""
# 72 ASCII characters = 72 bytes (boundary case)
password = "a" * 72
is_valid, error_msg = PasswordService.validate_password_strength(password)
self.assertTrue(is_valid)
self.assertEqual("", error_msg)

def test_validate_password_strength_exceeds_72_bytes(self):
"""Test that passwords over 72 UTF-8 bytes are rejected"""
# 73 ASCII characters = 73 bytes
is_valid, error_msg = PasswordService.validate_password_strength("a" * 73)
self.assertFalse(is_valid)
self.assertEqual("Password must be 72 characters or fewer", error_msg)

def test_validate_password_strength_multibyte_utf8_over_limit(self):
"""Test that multi-byte UTF-8 characters are counted by byte length"""
# Each '€' (U+20AC) encodes to 3 bytes — 25 chars = 75 bytes, over the limit
password = "€" * 25
self.assertGreater(len(password.encode("utf-8")), 72)
is_valid, error_msg = PasswordService.validate_password_strength(password)
self.assertFalse(is_valid)
self.assertEqual("Password must be 72 characters or fewer", error_msg)

def test_generate_temporary_password_default_word_count(self):
"""Test that generate_temporary_password produces 3-word password by default"""
password = PasswordService.generate_temporary_password()
Expand Down
Loading