diff --git a/backend/btrixcloud/users.py b/backend/btrixcloud/users.py index 52033c14..74a5da0d 100644 --- a/backend/btrixcloud/users.py +++ b/backend/btrixcloud/users.py @@ -19,6 +19,7 @@ from fastapi import ( ) from pymongo.errors import DuplicateKeyError +from pymongo.collation import Collation from .models import ( UserCreate, @@ -65,6 +66,8 @@ class UserManager: self.invites = invites self.org_ops = None + self.email_collation = Collation("en", strength=2) + self.registration_enabled = is_bool(os.environ.get("REGISTRATION_ENABLED")) # pylint: disable=attribute-defined-outside-init @@ -78,6 +81,13 @@ class UserManager: """init lookup index""" await self.users.create_index("id", unique=True) await self.users.create_index("email", unique=True) + + await self.users.create_index( + "email", + name="case_insensitive_email_index", + collation=self.email_collation, + ) + # Expire failed logins object after one hour await self.failed_logins.create_index("attempted", expireAfterSeconds=3600) @@ -379,7 +389,9 @@ class UserManager: async def get_by_email(self, email: str) -> Optional[User]: """get user by email""" - user = await self.users.find_one({"email": email}) + user = await self.users.find_one( + {"email": email}, collation=self.email_collation + ) if not user: return None @@ -535,7 +547,9 @@ class UserManager: async def reset_failed_logins(self, email: str) -> None: """Reset consecutive failed login attempts by deleting FailedLogin object""" - await self.failed_logins.delete_one({"email": email}) + await self.failed_logins.delete_one( + {"email": email}, collation=self.email_collation + ) async def inc_failed_logins(self, email: str) -> None: """Inc consecutive failed login attempts for user by 1 @@ -552,11 +566,14 @@ class UserManager: "$inc": {"count": 1}, }, upsert=True, + collation=self.email_collation, ) async def get_failed_logins_count(self, email: str) -> int: """Get failed login attempts for user, falling back to 0""" - failed_login = await self.failed_logins.find_one({"email": email}) + failed_login = await self.failed_logins.find_one( + {"email": email}, collation=self.email_collation + ) if not failed_login: return 0 return failed_login.get("count", 0) diff --git a/backend/test/conftest.py b/backend/test/conftest.py index b3a8d9ad..ebfdcd06 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -15,7 +15,8 @@ ADMIN_PW = "PASSW0RD!" VIEWER_USERNAME = "viewer@example.com" VIEWER_PW = "viewerPASSW0RD!" -CRAWLER_USERNAME = "crawler@example.com" +CRAWLER_USERNAME = "CraWleR@example.com" +CRAWLER_USERNAME_LOWERCASE = "crawler@example.com" CRAWLER_PW = "crawlerPASSWORD!" _admin_config_id = None diff --git a/backend/test/test_users.py b/backend/test/test_users.py index f7bbfe8d..f00af142 100644 --- a/backend/test/test_users.py +++ b/backend/test/test_users.py @@ -4,6 +4,8 @@ import time from .conftest import ( API_PREFIX, CRAWLER_USERNAME, + CRAWLER_USERNAME_LOWERCASE, + CRAWLER_PW, ADMIN_PW, ADMIN_USERNAME, FINISHED_STATES, @@ -14,7 +16,6 @@ VALID_USER_PW = "validpassw0rd!" VALID_USER_PW_RESET = "new!password" VALID_USER_PW_RESET_AGAIN = "new!password1" - my_id = None valid_user_headers = None @@ -71,6 +72,20 @@ def test_me_id(admin_auth_headers, default_org_id): assert r.status_code == 404 +def test_login_case_insensitive_email(): + r = requests.post( + f"{API_PREFIX}/auth/jwt/login", + data={ + "username": CRAWLER_USERNAME_LOWERCASE, + "password": CRAWLER_PW, + "grant_type": "password", + }, + ) + data = r.json() + assert r.status_code == 200 + assert data["access_token"] + + def test_add_user_to_org_invalid_password(admin_auth_headers, default_org_id): r = requests.post( f"{API_PREFIX}/orgs/{default_org_id}/add-user",