From 3fb34ebcc8f3f6a2243e144c1f23f98b2bb6c5d8 Mon Sep 17 00:00:00 2001 From: Tung Lam Date: Tue, 15 Sep 2026 02:07:19 +0000 Subject: [PATCH 1/2] fix(user): enforce every password character class on signup The declared pattern was an alternation, so any of its branches was enough: abcdefgh, aaaaaaaaaaaa and eight spaces were accepted while the description promised a number, an uppercase letter, a lowercase letter and a special character. A field_validator now rejects a password missing any of the four classes, and min_length stays. Pydantic compiles pattern with the Rust regex crate, which has no lookahead, so the check cannot be expressed as a corrected pattern. Refs #298 --- backend/src/modules/user/schemas.py | 21 ++++++++- .../tests/unit/modules/user/test_schemas.py | 47 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 backend/tests/unit/modules/user/test_schemas.py diff --git a/backend/src/modules/user/schemas.py b/backend/src/modules/user/schemas.py index 2565984b..b8e64fe9 100644 --- a/backend/src/modules/user/schemas.py +++ b/backend/src/modules/user/schemas.py @@ -1,7 +1,8 @@ +import re from datetime import datetime from typing import Annotated -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from ..common.schemas import PersistentDeletion, TimestampSchema from .constants import NAME_MAX_LENGTH, USERNAME_MAX_LENGTH, USERNAME_PATTERN @@ -68,7 +69,6 @@ class UserCreate(UserBase): "uppercase letter, lowercase letter, and special character" ), examples=["Str1ngst!"], - pattern=r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$", ), ] google_id: str | None = None @@ -78,6 +78,23 @@ class UserCreate(UserBase): oauth_created_at: datetime | None = None oauth_updated_at: datetime | None = None + @field_validator("password") + def validate_password_strength(cls, v: str) -> str: + """Validate the password includes every character class in the description.""" + if not re.search(r"[a-z]", v): + raise ValueError("Password must include at least one lowercase letter") + + if not re.search(r"[A-Z]", v): + raise ValueError("Password must include at least one uppercase letter") + + if not re.search(r"[0-9]", v): + raise ValueError("Password must include at least one number") + + if not re.search(r"[^a-zA-Z0-9]", v): + raise ValueError("Password must include at least one special character") + + return v + model_config = ConfigDict(extra="forbid") diff --git a/backend/tests/unit/modules/user/test_schemas.py b/backend/tests/unit/modules/user/test_schemas.py new file mode 100644 index 00000000..09d93df2 --- /dev/null +++ b/backend/tests/unit/modules/user/test_schemas.py @@ -0,0 +1,47 @@ +"""Unit tests for the User schemas.""" + +import pytest +from pydantic import ValidationError + +from src.modules.user.schemas import UserCreate + + +def _user_data(password: str) -> dict[str, str]: + return { + "name": "Test User", + "username": "testuser", + "email": "user.userson@example.com", + "password": password, + } + + +def test_password_with_every_character_class_is_accepted(): + """The documented example must keep working.""" + user = UserCreate(**_user_data("Str1ngst!")) + + assert user.password == "Str1ngst!" + + +@pytest.mark.parametrize( + ("password", "missing"), + [ + ("str1ngst!", "uppercase letter"), + ("STR1NGST!", "lowercase letter"), + ("Stringst!", "number"), + ("Str1ngst", "special character"), + ("abcdefgh", "uppercase letter"), + ("aaaaaaaaaaaa", "uppercase letter"), + (" ", "lowercase letter"), + ], +) +def test_password_missing_a_character_class_is_rejected(password: str, missing: str): + """A password is rejected when any class from the description is missing.""" + with pytest.raises(ValidationError, match=missing): + UserCreate(**_user_data(password)) + + +@pytest.mark.parametrize("password", ["Str1ng!", "Ab1!cde"]) +def test_password_shorter_than_eight_characters_is_rejected(password: str): + """Length stays enforced separately from the character classes.""" + with pytest.raises(ValidationError, match="at least 8 characters"): + UserCreate(**_user_data(password)) From 8f1505992c97255d2381339b9f6ed095104004e1 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Thu, 17 Sep 2026 21:19:53 -0300 Subject: [PATCH 2/2] check password character classes the way crudauth's policy does, and fix the docs that copy the old pattern --- backend/src/modules/user/constants.py | 8 +++++ backend/src/modules/user/schemas.py | 29 ++++++++++--------- .../tests/unit/modules/user/test_schemas.py | 13 +++++++++ .../authentication/user-management.md | 6 +++- docs/user-guide/database/schemas.md | 8 ++++- 5 files changed, 48 insertions(+), 16 deletions(-) diff --git a/backend/src/modules/user/constants.py b/backend/src/modules/user/constants.py index 5f9ac403..4856658e 100644 --- a/backend/src/modules/user/constants.py +++ b/backend/src/modules/user/constants.py @@ -3,3 +3,11 @@ NAME_MAX_LENGTH = 30 USERNAME_MAX_LENGTH = 32 USERNAME_PATTERN = r"^[a-z0-9_]+$" + +# Each class a signup password must contain, named as its error message names it. +PASSWORD_CHARACTER_CLASSES = ( + ("lowercase letter", str.islower), + ("uppercase letter", str.isupper), + ("number", str.isdecimal), + ("special character", lambda character: not character.isalnum()), +) diff --git a/backend/src/modules/user/schemas.py b/backend/src/modules/user/schemas.py index b8e64fe9..2a1754fc 100644 --- a/backend/src/modules/user/schemas.py +++ b/backend/src/modules/user/schemas.py @@ -1,11 +1,15 @@ -import re from datetime import datetime from typing import Annotated from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from ..common.schemas import PersistentDeletion, TimestampSchema -from .constants import NAME_MAX_LENGTH, USERNAME_MAX_LENGTH, USERNAME_PATTERN +from .constants import ( + NAME_MAX_LENGTH, + PASSWORD_CHARACTER_CLASSES, + USERNAME_MAX_LENGTH, + USERNAME_PATTERN, +) class UserBase(BaseModel): @@ -80,18 +84,15 @@ class UserCreate(UserBase): @field_validator("password") def validate_password_strength(cls, v: str) -> str: - """Validate the password includes every character class in the description.""" - if not re.search(r"[a-z]", v): - raise ValueError("Password must include at least one lowercase letter") - - if not re.search(r"[A-Z]", v): - raise ValueError("Password must include at least one uppercase letter") - - if not re.search(r"[0-9]", v): - raise ValueError("Password must include at least one number") - - if not re.search(r"[^a-zA-Z0-9]", v): - raise ValueError("Password must include at least one special character") + """Require a lowercase letter, an uppercase letter, a number and a special character. + + Classification is Unicode-aware: a Cyrillic password has lowercase + letters, and an accented letter counts as a letter, not as a special + character. + """ + for label, has_class in PASSWORD_CHARACTER_CLASSES: + if not any(has_class(character) for character in v): + raise ValueError(f"Password must include at least one {label}") return v diff --git a/backend/tests/unit/modules/user/test_schemas.py b/backend/tests/unit/modules/user/test_schemas.py index 09d93df2..9c6b6d9f 100644 --- a/backend/tests/unit/modules/user/test_schemas.py +++ b/backend/tests/unit/modules/user/test_schemas.py @@ -40,6 +40,19 @@ def test_password_missing_a_character_class_is_rejected(password: str, missing: UserCreate(**_user_data(password)) +def test_a_non_latin_password_is_accepted(): + """Character classes are Unicode-aware, so a Cyrillic password has lowercase letters.""" + user = UserCreate(**_user_data("Пароль1!")) + + assert user.password == "Пароль1!" + + +def test_an_accented_letter_is_not_a_special_character(): + """``é`` is a letter, so it doesn't satisfy the special-character requirement.""" + with pytest.raises(ValidationError, match="special character"): + UserCreate(**_user_data("Senhaé123")) + + @pytest.mark.parametrize("password", ["Str1ng!", "Ab1!cde"]) def test_password_shorter_than_eight_characters_is_rejected(password: str): """Length stays enforced separately from the character classes.""" diff --git a/docs/user-guide/authentication/user-management.md b/docs/user-guide/authentication/user-management.md index 44c3999e..12e80a69 100644 --- a/docs/user-guide/authentication/user-management.md +++ b/docs/user-guide/authentication/user-management.md @@ -70,10 +70,14 @@ class UserCreate(UserBase): str, Field( min_length=8, - pattern=r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$", examples=["Str1ngst!"], ), ] + + @field_validator("password") + def validate_password_strength(cls, v: str) -> str: + """Reject a password missing any of the four character classes.""" + ... # OAuth fields (filled when user signs up via Google) google_id: str | None = None github_id: str | None = None diff --git a/docs/user-guide/database/schemas.md b/docs/user-guide/database/schemas.md index b400af4a..346ed911 100644 --- a/docs/user-guide/database/schemas.md +++ b/docs/user-guide/database/schemas.md @@ -99,9 +99,15 @@ class UserCreate(UserBase): "uppercase letter, lowercase letter, and special character" ), examples=["Str1ngst!"], - pattern=r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$", ), ] + + @field_validator("password") + def validate_password_strength(cls, v: str) -> str: + for label, has_class in PASSWORD_CHARACTER_CLASSES: + if not any(has_class(character) for character in v): + raise ValueError(f"Password must include at least one {label}") + return v # OAuth fields — populated when user signs up via Google/GitHub google_id: str | None = None github_id: str | None = None