Skip to content

Commit c52eaf6

Browse files
authored
feat: More password requirements (M2-10505) (#2036)
πŸ”— [Jira Ticket M2-10505](https://mindlogger.atlassian.net/browse/M2-10505) Changes include: - NFKC normalize password first - Disallow all unicode whitespace - Increase minimum length to 10 grapheme clusters - Require at least 3 character types of uppercase, lowercase, caseless, number, and symbol
1 parent 280179a commit c52eaf6

28 files changed

Lines changed: 321 additions & 84 deletions

β€Ž.env.defaultβ€Ž

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
PYTHONPATH=src/
44

5+
56
# PostgreSQL configurations
67

78
DATABASE__HOST=localhost
@@ -12,12 +13,14 @@ DATABASE__DB=mindlogger_backend
1213

1314

1415
# Redis configuration
16+
1517
REDIS__HOST=localhost
1618
REDIS__MFA_SESSION_TTL=300
1719
REDIS__MFA_MAX_ATTEMPTS=5
1820
REDIS__MFA_GLOBAL_LOCKOUT_ATTEMPTS=10
1921
REDIS__MFA_GLOBAL_LOCKOUT_TTL=900
2022

23+
2124
# Application configurations
2225

2326
# CORS
@@ -27,8 +30,6 @@ CORS__ALLOW_CREDENTIALS=true
2730
CORS__ALLOW_METHODS=*
2831
CORS__ALLOW_HEADERS=*
2932

30-
31-
3233
# Authentication
3334
AUTHENTICATION__ACCESS_TOKEN__SECRET_KEY="secret1"
3435
AUTHENTICATION__REFRESH_TOKEN__SECRET_KEY="secret2"
@@ -41,6 +42,19 @@ AUTHENTICATION__PASSWORD_RECOVER__EXPIRATION=900
4142
AUTHENTICATION__REFRESH_TOKEN__TRANSITION_KEY=
4243
#AUTHENTICATION__REFRESH_TOKEN__TRANSITION_EXPIRE_DATE=
4344

45+
# MFA (Multi-Factor Authentication) settings
46+
# Generate a new key using: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
47+
MFA__TOTP_ENCRYPTION_KEY=_laj8VzSPpUSUTMxb1gISn37xjVz4zFpTUHd3wm3AFw=
48+
MFA__TOTP_ISSUER_NAME=MindLogger
49+
MFA__TOTP_VALID_WINDOW=1
50+
MFA__PENDING_MFA_EXPIRATION_SECONDS=600
51+
MFA__RECOVERY_CODE_ENCRYPTION_KEY=xK9vL2mN4pQ6rS8tU0wV1yA3bC5dE7fG9hI1jK3lM5n=
52+
MFA__RECOVERY_CODE_COUNT=10
53+
MFA__RECOVERY_CODE_LENGTH=10
54+
55+
# Password validation
56+
PASSWORD__MIN_LENGTH=10
57+
PASSWORD__MIN_CHARACTER_TYPES=3
4458

4559
# Mailing
4660
MAILING__MAIL__USERNAME=mailhog
@@ -90,19 +104,8 @@ RABBITMQ__URL=localhost
90104
# Secret key for data encryption. Use this key only for local development
91105
SECRETS__SECRET_KEY=0eb7f5d4c1367199c21e9a2ec793b5a481b60fe2af24464bcb18ac7fa48a645f
92106

93-
94107
MULTI_INFORMANT__TEMP_RELATION_EXPIRY_SECS=86400
95108

96-
# MFA (Multi-Factor Authentication) settings
97-
# Generate a new key using: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
98-
MFA__TOTP_ENCRYPTION_KEY=_laj8VzSPpUSUTMxb1gISn37xjVz4zFpTUHd3wm3AFw=
99-
MFA__TOTP_ISSUER_NAME=MindLogger
100-
MFA__TOTP_VALID_WINDOW=1
101-
MFA__PENDING_MFA_EXPIRATION_SECONDS=600
102-
MFA__RECOVERY_CODE_ENCRYPTION_KEY=xK9vL2mN4pQ6rS8tU0wV1yA3bC5dE7fG9hI1jK3lM5n=
103-
MFA__RECOVERY_CODE_COUNT=10
104-
MFA__RECOVERY_CODE_LENGTH=10
105-
106109
# 1upHealth
107110
ONEUP_HEALTH__CLIENT_ID=
108111
ONEUP_HEALTH__CLIENT_SECRET=

β€Žpyproject.tomlβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dependencies = [
4343
"uvicorn[standard]==0.38.*",
4444
"orjson>=3.11.4",
4545
"pydantic-extra-types>=2.0",
46+
"regex>=2025.11.3",
4647
]
4748

4849
[tool.ruff]

β€Žsrc/apps/answers/tests/test_answers.pyβ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ async def olive(olive_create: UserCreate, global_session: AsyncSession, pytestco
103103
def sam_create() -> UserCreate:
104104
return UserCreate(
105105
email="sam@mindlogger.com",
106-
password="Test1234!",
106+
password="Test12345!",
107107
first_name="Sam",
108108
last_name="Smith",
109109
)
@@ -113,7 +113,7 @@ def sam_create() -> UserCreate:
113113
def olive_create() -> UserCreate:
114114
return UserCreate(
115115
email="olive@mindlogger.com",
116-
password="Test1234!",
116+
password="Test12345!",
117117
first_name="Olive",
118118
last_name="Johnson",
119119
)

β€Žsrc/apps/applets/tests/test_applet_seed.pyβ€Ž

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def user_details(last_name: str) -> dict:
5555
"email": f"{last_name.lower()}{uuid_prefix()}@email.com",
5656
"first_name": "Example",
5757
"last_name": last_name,
58-
"password": "password",
58+
"password": "Test12345!",
5959
"subject_id": uuid.uuid4(),
6060
"secret_user_id": uuid.uuid4(),
6161
"nickname": f"Applet {last_name}",
@@ -87,7 +87,7 @@ async def test_seed_applet_successfully(self, session: AsyncSession):
8787
"email": "someone@example.com",
8888
"first_name": "Someone",
8989
"last_name": "Owner",
90-
"password": "password",
90+
"password": "Test12345!",
9191
"subject_id": uuid.uuid4(),
9292
"secret_user_id": uuid.uuid4(),
9393
"nickname": "Applet Owner",
@@ -472,13 +472,13 @@ async def test_seed_duplicate_user_ids(self):
472472
email: user1@example.com
473473
first_name: First
474474
last_name: Last
475-
password: password
475+
password: Test12345!
476476
- id: {user_id}
477477
created_at: 2023-10-01T00:00:00Z
478478
email: user2@example.com
479479
first_name: First
480480
last_name: Last
481-
password: password
481+
password: Test12345!
482482
"""
483483
with pytest.raises(DuplicateUserIdsError):
484484
await seed_config(config)
@@ -495,13 +495,13 @@ async def test_seed_duplicate_user_emails(self):
495495
email: {user_email}
496496
first_name: First
497497
last_name: Last
498-
password: password
498+
password: Test12345!
499499
- id: {uuid.uuid4()}
500500
created_at: 2023-10-01T00:00:00Z
501501
email: {user_email}
502502
first_name: First
503503
last_name: Last
504-
password: password
504+
password: Test12345!
505505
"""
506506
with pytest.raises(DuplicateUserEmailsError):
507507
await seed_config(config)
@@ -520,7 +520,7 @@ async def test_seed_duplicate_applet_ids(self):
520520
email: {user_email}
521521
first_name: First
522522
last_name: Last
523-
password: password
523+
password: Test12345!
524524
525525
applets:
526526
- id: {applet_id}
@@ -590,7 +590,7 @@ async def test_seed_existing_applet_id(self, applet_one: AppletFull):
590590
email: {user_email}
591591
first_name: First
592592
last_name: Last
593-
password: password
593+
password: Test12345!
594594
595595
applets:
596596
- id: {applet_one.id}
@@ -675,7 +675,7 @@ async def test_seed_applet_no_activities(self):
675675
email: {user_email}
676676
first_name: First
677677
last_name: Last
678-
password: password
678+
password: Test12345!
679679
680680
applets:
681681
- id: {uuid.uuid4()}
@@ -715,7 +715,7 @@ async def test_seed_undefined_subject_user(self):
715715
email: {user_email}
716716
first_name: First
717717
last_name: Last
718-
password: password
718+
password: Test12345!
719719
720720
applets:
721721
- id: {uuid.uuid4()}
@@ -800,7 +800,7 @@ async def test_seed_duplicate_subject_secret_ids(self):
800800
email: {user_email}
801801
first_name: First
802802
last_name: Last
803-
password: password
803+
password: Test12345!
804804
805805
applets:
806806
- id: {uuid.uuid4()}
@@ -850,7 +850,7 @@ async def test_seed_applet_without_owner(self):
850850
email: {user_email}
851851
first_name: First
852852
last_name: Last
853-
password: password
853+
password: Test12345!
854854
855855
applets:
856856
- id: {uuid.uuid4()}
@@ -898,12 +898,12 @@ async def test_seed_applet_with_multiple_owners(self):
898898
email: {user_email}
899899
first_name: First
900900
last_name: Last
901-
password: password
901+
password: Test12345!
902902
- id: {user2_id}
903903
email: {user2_email}
904904
first_name: First
905905
last_name: Last
906-
password: password
906+
password: Test12345!
907907
908908
applets:
909909
- id: {uuid.uuid4()}
@@ -957,7 +957,7 @@ async def test_seed_duplicate_subject_ids(self):
957957
email: {user_email}
958958
first_name: First
959959
last_name: Last
960-
password: password
960+
password: Test12345!
961961
962962
applets:
963963
- id: {uuid.uuid4()}
@@ -1007,7 +1007,7 @@ async def test_seed_invalid_first_event_periodicity(self):
10071007
email: {user_email}
10081008
first_name: First
10091009
last_name: Last
1010-
password: password
1010+
password: Test12345!
10111011
10121012
applets:
10131013
- id: {uuid.uuid4()}
@@ -1055,7 +1055,7 @@ async def test_seed_invalid_first_event_schedule(self):
10551055
email: {user_email}
10561056
first_name: First
10571057
last_name: Last
1058-
password: password
1058+
password: Test12345!
10591059
10601060
applets:
10611061
- id: {uuid.uuid4()}
@@ -1103,7 +1103,7 @@ async def test_seed_subject_missing_respondent_role(self):
11031103
email: {user_email}
11041104
first_name: First
11051105
last_name: Last
1106-
password: password
1106+
password: Test12345!
11071107
11081108
applets:
11091109
- id: {uuid.uuid4()}

β€Žsrc/apps/authentication/services/security.pyβ€Ž

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from apps.shared.bcrypt import get_password_hash, verify
1717
from apps.users.cruds.user import UsersCRUD
1818
from apps.users.domain import User
19+
from apps.users.password_validation import PasswordValidator
1920
from config import settings
2021

2122
__all__ = ["AuthenticationService"]
@@ -121,14 +122,20 @@ def create_refresh_token(data: dict) -> str:
121122

122123
@staticmethod
123124
def verify_password(plain_password: str, hashed_password: str, raise_exception=True) -> bool:
124-
valid = verify(plain_password, hashed_password)
125-
if not valid and raise_exception:
125+
normalized = PasswordValidator.normalize(plain_password)
126+
if verify(normalized, hashed_password):
127+
return True
128+
# Fallback: try without normalization for pre-existing hashes
129+
if normalized != plain_password and verify(plain_password, hashed_password):
130+
return True
131+
if raise_exception:
126132
raise BadCredentials()
127-
return valid
133+
return False
128134

129135
@staticmethod
130136
def get_password_hash(password: str) -> str:
131-
return get_password_hash(password)
137+
normalized = PasswordValidator.normalize(password)
138+
return get_password_hash(normalized)
132139

133140
async def authenticate_user(self, user_login_schema: UserLoginRequest) -> User:
134141
user: User = await UsersCRUD(self.session).get_by_email(email=user_login_schema.email)

β€Žsrc/apps/authentication/tests/test_auth.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from apps.users.domain import User, UserCreate, UserCreateRequest
2121
from config import settings
2222

23-
TEST_PASSWORD = "Test1234!"
23+
TEST_PASSWORD = "Test12345!"
2424

2525

2626
class TestAuthentication(BaseTest):

β€Žsrc/apps/authentication/tests/test_mfa_flow.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from apps.users.domain import User
1717
from apps.users.services.totp import totp_service
1818

19-
TEST_PASSWORD = "Test1234!"
19+
TEST_PASSWORD = "Test12345!"
2020

2121

2222
@pytest.fixture

β€Žsrc/apps/authentication/tests/test_mfa_rate_limiting.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from apps.users.services.totp import totp_service
1717
from config import settings
1818

19-
TEST_PASSWORD = "Test1234!"
19+
TEST_PASSWORD = "Test12345!"
2020

2121

2222
@pytest.fixture

β€Žsrc/apps/authentication/tests/test_recovery_code_privacy.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from apps.users.domain import User
2323
from apps.users.services.totp import totp_service
2424

25-
TEST_PASSWORD = "Test1234!"
25+
TEST_PASSWORD = "Test12345!"
2626

2727

2828
@pytest.mark.usefixtures("user")

β€Žsrc/apps/authentication/tests/test_recovery_code_verification.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from apps.users.services.totp import totp_service
1919
from config import settings
2020

21-
TEST_PASSWORD = "Test1234!"
21+
TEST_PASSWORD = "Test12345!"
2222

2323

2424
@pytest.fixture

0 commit comments

Comments
Β (0)