Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
29 changes: 16 additions & 13 deletions .env.default
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

PYTHONPATH=src/


# PostgreSQL configurations

DATABASE__HOST=localhost
Expand All @@ -12,12 +13,14 @@ DATABASE__DB=mindlogger_backend


# Redis configuration

REDIS__HOST=localhost
REDIS__MFA_SESSION_TTL=300
REDIS__MFA_MAX_ATTEMPTS=5
REDIS__MFA_GLOBAL_LOCKOUT_ATTEMPTS=10
REDIS__MFA_GLOBAL_LOCKOUT_TTL=900


# Application configurations

# CORS
Expand All @@ -27,8 +30,6 @@ CORS__ALLOW_CREDENTIALS=true
CORS__ALLOW_METHODS=*
CORS__ALLOW_HEADERS=*



# Authentication
AUTHENTICATION__ACCESS_TOKEN__SECRET_KEY="secret1"
AUTHENTICATION__REFRESH_TOKEN__SECRET_KEY="secret2"
Expand All @@ -41,6 +42,19 @@ AUTHENTICATION__PASSWORD_RECOVER__EXPIRATION=900
AUTHENTICATION__REFRESH_TOKEN__TRANSITION_KEY=
#AUTHENTICATION__REFRESH_TOKEN__TRANSITION_EXPIRE_DATE=

# MFA (Multi-Factor Authentication) settings
# Generate a new key using: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MFA__TOTP_ENCRYPTION_KEY=_laj8VzSPpUSUTMxb1gISn37xjVz4zFpTUHd3wm3AFw=
MFA__TOTP_ISSUER_NAME=MindLogger
MFA__TOTP_VALID_WINDOW=1
MFA__PENDING_MFA_EXPIRATION_SECONDS=600
MFA__RECOVERY_CODE_ENCRYPTION_KEY=xK9vL2mN4pQ6rS8tU0wV1yA3bC5dE7fG9hI1jK3lM5n=
MFA__RECOVERY_CODE_COUNT=10
MFA__RECOVERY_CODE_LENGTH=10

# Password validation
PASSWORD__MIN_LENGTH=10
PASSWORD__MIN_CHARACTER_TYPES=3

# Mailing
MAILING__MAIL__USERNAME=mailhog
Expand Down Expand Up @@ -90,19 +104,8 @@ RABBITMQ__URL=localhost
# Secret key for data encryption. Use this key only for local development
SECRETS__SECRET_KEY=0eb7f5d4c1367199c21e9a2ec793b5a481b60fe2af24464bcb18ac7fa48a645f


MULTI_INFORMANT__TEMP_RELATION_EXPIRY_SECS=86400

# MFA (Multi-Factor Authentication) settings
# Generate a new key using: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MFA__TOTP_ENCRYPTION_KEY=_laj8VzSPpUSUTMxb1gISn37xjVz4zFpTUHd3wm3AFw=
MFA__TOTP_ISSUER_NAME=MindLogger
MFA__TOTP_VALID_WINDOW=1
MFA__PENDING_MFA_EXPIRATION_SECONDS=600
MFA__RECOVERY_CODE_ENCRYPTION_KEY=xK9vL2mN4pQ6rS8tU0wV1yA3bC5dE7fG9hI1jK3lM5n=
MFA__RECOVERY_CODE_COUNT=10
MFA__RECOVERY_CODE_LENGTH=10

# 1upHealth
ONEUP_HEALTH__CLIENT_ID=
ONEUP_HEALTH__CLIENT_SECRET=
Expand Down
4 changes: 2 additions & 2 deletions src/apps/answers/tests/test_answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async def olive(olive_create: UserCreate, global_session: AsyncSession, pytestco
def sam_create() -> UserCreate:
return UserCreate(
email="sam@mindlogger.com",
password="Test1234!",
password="Test12345!",
first_name="Sam",
last_name="Smith",
)
Expand All @@ -113,7 +113,7 @@ def sam_create() -> UserCreate:
def olive_create() -> UserCreate:
return UserCreate(
email="olive@mindlogger.com",
password="Test1234!",
password="Test12345!",
first_name="Olive",
last_name="Johnson",
)
Expand Down
4 changes: 2 additions & 2 deletions src/apps/applets/tests/test_applet_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def user_details(last_name: str) -> dict:
"email": f"{last_name.lower()}{uuid_prefix()}@email.com",
"first_name": "Example",
"last_name": last_name,
"password": "password",
"password": "Test12345!",
"subject_id": uuid.uuid4(),
"secret_user_id": uuid.uuid4(),
"nickname": f"Applet {last_name}",
Expand Down Expand Up @@ -87,7 +87,7 @@ async def test_seed_applet_successfully(self, session: AsyncSession):
"email": "someone@example.com",
"first_name": "Someone",
"last_name": "Owner",
"password": "password",
"password": "Test12345!",
"subject_id": uuid.uuid4(),
"secret_user_id": uuid.uuid4(),
"nickname": "Applet Owner",
Expand Down
15 changes: 11 additions & 4 deletions src/apps/authentication/services/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from apps.shared.bcrypt import get_password_hash, verify
from apps.users.cruds.user import UsersCRUD
from apps.users.domain import User
from apps.users.password_validation import PasswordValidator
from config import settings

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

@staticmethod
def verify_password(plain_password: str, hashed_password: str, raise_exception=True) -> bool:
valid = verify(plain_password, hashed_password)
if not valid and raise_exception:
normalized = PasswordValidator.normalize(plain_password)
if verify(normalized, hashed_password):
return True
# Fallback: try without normalization for pre-existing hashes
if normalized != plain_password and verify(plain_password, hashed_password):
return True
if raise_exception:
raise BadCredentials()
return valid
return False

@staticmethod
def get_password_hash(password: str) -> str:
return get_password_hash(password)
normalized = PasswordValidator.normalize(password)
return get_password_hash(normalized)

async def authenticate_user(self, user_login_schema: UserLoginRequest) -> User:
user: User = await UsersCRUD(self.session).get_by_email(email=user_login_schema.email)
Expand Down
2 changes: 1 addition & 1 deletion src/apps/authentication/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from apps.users.domain import User, UserCreate, UserCreateRequest
from config import settings

TEST_PASSWORD = "Test1234!"
TEST_PASSWORD = "Test12345!"


class TestAuthentication(BaseTest):
Expand Down
2 changes: 1 addition & 1 deletion src/apps/authentication/tests/test_mfa_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from apps.users.domain import User
from apps.users.services.totp import totp_service

TEST_PASSWORD = "Test1234!"
TEST_PASSWORD = "Test12345!"


@pytest.fixture
Expand Down
2 changes: 1 addition & 1 deletion src/apps/authentication/tests/test_mfa_rate_limiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from apps.users.services.totp import totp_service
from config import settings

TEST_PASSWORD = "Test1234!"
TEST_PASSWORD = "Test12345!"


@pytest.fixture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from apps.users.domain import User
from apps.users.services.totp import totp_service

TEST_PASSWORD = "Test1234!"
TEST_PASSWORD = "Test12345!"


@pytest.mark.usefixtures("user")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from apps.users.services.totp import totp_service
from config import settings

TEST_PASSWORD = "Test1234!"
TEST_PASSWORD = "Test12345!"


@pytest.fixture
Expand Down
2 changes: 1 addition & 1 deletion src/apps/authentication/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from apps.users.domain import User
from config import settings

TEST_PASSWORD = "Test1234!"
TEST_PASSWORD = "Test12345!"
RJTI = str(uuid.uuid4())


Expand Down
2 changes: 1 addition & 1 deletion src/apps/authentication/tests/unit/test_auth_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from apps.users.errors import UserNotFound
from config import settings

TEST_PASSWORD = "Test1234!"
TEST_PASSWORD = "Test12345!"
RJTI = str(uuid.uuid4())


Expand Down
21 changes: 20 additions & 1 deletion src/apps/authentication/tests/unit/test_auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from apps.users.domain import User
from apps.users.errors import UserIsDeletedError, UserNotFound

TEST_PASSWORD = "Test1234!"
TEST_PASSWORD = "Test12345!"
RJTI = str(uuid.uuid4())


Expand All @@ -38,6 +38,25 @@ def test_verify_password(self, auth_service: AuthenticationService):
valid = auth_service.verify_password(password, hashed_password)
assert valid

def test_verify_password__normalized_hash(self, auth_service: AuthenticationService):
"""n + combining ~ (decomposed) and 帽 (composed) should NFKC normalize and verify against each other."""
decomposed = "grancaban\u0303a" # grancaba帽a with n + combining ~ (decomposed)
composed = "grancaba\u00f1a" # grancaba帽a with 帽 (composed)
decomposed_hash = auth_service.get_password_hash(decomposed)
composed_hash = auth_service.get_password_hash(composed)
assert auth_service.verify_password(composed, decomposed_hash)
assert auth_service.verify_password(decomposed, decomposed_hash)
assert auth_service.verify_password(composed, composed_hash)
assert auth_service.verify_password(decomposed, composed_hash)

def test_verify_password__unnormalized_hash(self, auth_service: AuthenticationService):
"""Old hashes from unnormalized input should still verify via fallback."""
from apps.shared.bcrypt import get_password_hash as raw_get_password_hash

decomposed = "grancaban\u0303a" # grancaba帽a with n + combining ~ (decomposed)
unnormalized_hash = raw_get_password_hash(decomposed) # old hash without NFKC normalization
assert auth_service.verify_password(decomposed, unnormalized_hash)

async def test_authenticate_user__creds_are_not_valid(self, auth_service: AuthenticationService, user: User):
login_schema = UserLoginRequest(email=user.email_encrypted, password="notvalidpassword")
with pytest.raises(InvalidCredentials):
Expand Down
2 changes: 1 addition & 1 deletion src/apps/invitations/test_invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def user_create_data() -> UserCreateRequest:
email="tom2@mindlogger.com",
first_name="Tom",
last_name="Isaak",
password="Test1234!",
password="Test12345!",
)


Expand Down
4 changes: 2 additions & 2 deletions src/apps/subjects/tests/tests_arbitrary.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ async def test_successfully_delete_subject_without_answers_arbitrary(
self, session, arbitrary_session, arbitrary_client, answer_create_payload
):
subject_id = uuid.UUID("a7feb119-dccb-46b1-bd46-60e5af694de4")
await arbitrary_client.login(self.login_url, "ivan@mindlogger.com", "Test1234!")
await arbitrary_client.login(self.login_url, "ivan@mindlogger.com", "Test12345!")
response = await arbitrary_client.post(self.answer_url, data=answer_create_payload)

assert response.status_code == http.HTTPStatus.CREATED
Expand All @@ -81,7 +81,7 @@ async def test_successfully_delete_subject_with_answers_arbitrary(
self, session, arbitrary_session, arbitrary_client, answer_create_payload, mock_kiq_report
):
subject_id = uuid.UUID("a7feb119-dccb-46b1-bd46-60e5af694de4")
await arbitrary_client.login(self.login_url, "ivan@mindlogger.com", "Test1234!")
await arbitrary_client.login(self.login_url, "ivan@mindlogger.com", "Test12345!")
response = await arbitrary_client.post(self.answer_url, data=answer_create_payload)

assert response.status_code == http.HTTPStatus.CREATED
Expand Down
4 changes: 2 additions & 2 deletions src/apps/users/commands/tests/test_manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ async def regular_user(self, global_session: AsyncSession) -> AsyncGenerator[Use

user_create = UserCreate(
email=self.USER_EMAIL,
password="Test1234!",
password="Test12345!",
first_name=self.USER_FIRSTNAME,
last_name=self.USER_LASTNAME,
)
Expand All @@ -68,7 +68,7 @@ async def deleted_user(self, global_session: AsyncSession) -> AsyncGenerator[Use

user_create = UserCreate(
email=self.USER_DELETED_EMAIL,
password="Test1234!",
password="Test12345!",
first_name=self.USER_DELETED_FIRSTNAME,
last_name=self.USER_DELETED_LASTNAME,
)
Expand Down
23 changes: 14 additions & 9 deletions src/apps/users/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from apps.shared.domain import InternalModel, PublicModel
from apps.shared.hashing import hash_sha224
from apps.users.db.schemas import UserDeviceSchema
from apps.users.errors import PasswordHasSpacesError
from apps.users.password_validation import PasswordValidator

__all__ = [
"PublicUser",
Expand Down Expand Up @@ -57,16 +57,13 @@ class UserCreateRequest(PublicModel):
str,
Field(
description="This field represents the user password",
min_length=1,
),
]

@field_validator("password")
@classmethod
def validate_password(cls, value: str) -> str:
if " " in value:
raise PasswordHasSpacesError()
return value
return PasswordValidator.validate(value)

@field_validator("email")
@classmethod
Expand Down Expand Up @@ -158,12 +155,15 @@ class ChangePasswordRequest(InternalModel):
password: str
prev_password: str

@field_validator("password", "prev_password")
@field_validator("password")
@classmethod
def validate_password(cls, value: str) -> str:
if " " in value:
raise PasswordHasSpacesError()
return value
return PasswordValidator.validate(value)

@field_validator("prev_password")
@classmethod
def normalize_prev_password(cls, value: str) -> str:
return PasswordValidator.normalize(value)


class UserChangePassword(InternalModel):
Expand Down Expand Up @@ -198,6 +198,11 @@ class PasswordRecoveryApproveRequest(InternalModel):
key: uuid.UUID
password: str

@field_validator("password")
@classmethod
def validate_password(cls, value: str) -> str:
return PasswordValidator.validate(value)


class AppInfoOS(PublicModel):
name: str
Expand Down
20 changes: 19 additions & 1 deletion src/apps/users/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,25 @@ class PasswordRecoveryKeyNotFound(NotFoundError):


class PasswordHasSpacesError(ValidationError):
message = _("Password should not contain blank spaces")
message = _("Password must not contain spaces.")


class PasswordContainsInvalidCharactersError(ValidationError):
message = _("Password must not contain control characters.")


class PasswordTooShortError(ValidationError):
message_is_template: bool = True
message = _("Password must be at least {chars} characters.")


class PasswordInsufficientTypesError(ValidationError):
message_is_template: bool = True
message = _("Password must contain at least {types} of: uppercase, lowercase, number, symbol")


class PasswordTooCommonError(ValidationError): # Phase 2
message = _("Password is too common or easily guessable.")


class UserIsDeletedError(NotFoundError):
Expand Down
Loading
Loading