From 342f76c1fe4c99af9f1ce007ed4c76892668e6b8 Mon Sep 17 00:00:00 2001 From: Prasanna Date: Fri, 24 Apr 2026 22:59:35 +0530 Subject: [PATCH 01/24] fix: add username helper --- routes/auth.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 0d086f4d..491df52e 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -27,6 +27,22 @@ OTP_VERIFICATION_WINDOW_SECONDS = 600 # 10 minutes +def _unique_username(base: str) -> str: + """Return a username derived from *base* that does not already exist. + + Strips any '@' character so the result can never be mistaken for an email + address by the login resolver, then appends an incrementing numeric suffix + until a free slot is found. + """ + base = base.replace("@", "") + candidate = base + counter = 1 + while db.users.find_one({"username": candidate}): + candidate = f"{base}{counter}" + counter += 1 + return candidate + + def _validate_otp_verification(email: str): """Check that a valid, unexpired OTP verification session exists for email. @@ -331,9 +347,10 @@ def set_password(): role = "admin" if is_admin_email(email) else "user" now_utc = datetime.now(timezone.utc) + username = _unique_username(email.split("@")[0]) user_id = db.users.insert_one({ "email": email, - "username": email.split("@")[0], + "username": username, "password": hashed, "role": role, "created_at": now_utc, @@ -415,9 +432,10 @@ def google_auth(): # Create a minimal Google-backed user (no local password) now_utc = datetime.now(timezone.utc) + google_username = _unique_username(name or email.split("@")[0]) result = db.users.insert_one({ "email": email, - "username": name or email.split("@")[0], + "username": google_username, "password": None, "role": role, "provider": "google", From 62d90efcfef1d7880f046ad3100977f693eba9fa Mon Sep 17 00:00:00 2001 From: Prasanna Date: Fri, 24 Apr 2026 23:11:13 +0530 Subject: [PATCH 02/24] feat: add test for set_password --- tests/test_set_password.py | 79 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_set_password.py b/tests/test_set_password.py index 7db481f8..da351f9d 100644 --- a/tests/test_set_password.py +++ b/tests/test_set_password.py @@ -166,3 +166,82 @@ def test_set_password_signup_duplicate_email(mock_db, client): assert resp.status_code == 400 data = resp.get_json() assert data["error"] == "User already exists" + + +# --------------------------------------------------------------------------- +# Tests — username collision handling +# --------------------------------------------------------------------------- + + +@patch("routes.auth.is_admin_email", return_value=False) +@patch("routes.auth.db") +@patch("routes.auth.create_access_token", return_value="mock-jwt-token") +def test_set_password_signup_username_collision_resolved(mock_token, mock_db, mock_admin, client): + """When the email prefix is already taken as a username, _unique_username + must generate a suffixed variant instead of silently inserting a duplicate.""" + otp_record = { + "email": "alice@example.com", + "verified": True, + "verified_at": __import__("datetime").datetime.now(__import__("datetime").timezone.utc), + } + + inserted_doc = MagicMock(inserted_id="new-id-456") + mock_db.users.insert_one.return_value = inserted_doc + + # Simulate: first find_one (existing-user check) → None, + # second find_one (_unique_username "alice" taken) → existing record, + # third find_one (_unique_username "alice1" free) → None, + # fourth find_one (OTP verification) → otp_record. + mock_db.users.find_one.side_effect = [ + None, # existing user by email — not found + {"_id": "x"}, # "alice" username already taken + None, # "alice1" username is free + ] + mock_db.email_otps.find_one.return_value = otp_record + + resp = _post_set_password(client, { + "email": "alice@example.com", + "password": "securepassword123", + "purpose": "signup", + }) + + assert resp.status_code == 200 + + # Extract the username that was actually persisted + call_args = mock_db.users.insert_one.call_args[0][0] + assert call_args["username"] == "alice1", ( + f"Expected 'alice1' but got '{call_args['username']}'" + ) + + +@patch("routes.auth.db") +def test_unique_username_no_collision(mock_db): + """_unique_username returns the base name unchanged when it is free.""" + from routes.auth import _unique_username + + mock_db.users.find_one.return_value = None + assert _unique_username("bob") == "bob" + + +@patch("routes.auth.db") +def test_unique_username_strips_at_sign(mock_db): + """_unique_username must strip '@' so the result cannot be parsed as email.""" + from routes.auth import _unique_username + + mock_db.users.find_one.return_value = None + result = _unique_username("user@domain") + assert "@" not in result + + +@patch("routes.auth.db") +def test_unique_username_increments_until_free(mock_db): + """_unique_username must keep incrementing the suffix until a slot is free.""" + from routes.auth import _unique_username + + # "carol" and "carol1" are taken; "carol2" is free + mock_db.users.find_one.side_effect = [ + {"_id": "1"}, # "carol" taken + {"_id": "2"}, # "carol1" taken + None, # "carol2" free + ] + assert _unique_username("carol") == "carol2" From 503bc92c69604b8ae6d3ba4a346d88cf56e1499f Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Fri, 24 Apr 2026 23:23:01 +0530 Subject: [PATCH 03/24] Update routes/auth.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- routes/auth.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 491df52e..fd98c17a 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -30,11 +30,11 @@ def _unique_username(base: str) -> str: """Return a username derived from *base* that does not already exist. - Strips any '@' character so the result can never be mistaken for an email - address by the login resolver, then appends an incrementing numeric suffix - until a free slot is found. + Strips any '@' or '$' characters so the result can never be mistaken for + an email address or trigger NoSQL injection checks, then appends an + incrementing numeric suffix until a free slot is found. """ - base = base.replace("@", "") + base = base.replace("@", "").replace("$", "") candidate = base counter = 1 while db.users.find_one({"username": candidate}): From 3a6a57f86c947a0ab2ca2a3dbb0b28c368542d77 Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Fri, 24 Apr 2026 23:23:36 +0530 Subject: [PATCH 04/24] Update tests/test_set_password.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tests/test_set_password.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_set_password.py b/tests/test_set_password.py index da351f9d..9f28018d 100644 --- a/tests/test_set_password.py +++ b/tests/test_set_password.py @@ -182,7 +182,7 @@ def test_set_password_signup_username_collision_resolved(mock_token, mock_db, mo otp_record = { "email": "alice@example.com", "verified": True, - "verified_at": __import__("datetime").datetime.now(__import__("datetime").timezone.utc), + "verified_at": datetime.now(timezone.utc), } inserted_doc = MagicMock(inserted_id="new-id-456") From cbf2f22901dd7ddb970f7f240d3d49fd576fa642 Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Sat, 25 Apr 2026 21:25:41 +0530 Subject: [PATCH 05/24] Update routes/auth.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/auth.py b/routes/auth.py index fd98c17a..5e638be8 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -34,7 +34,7 @@ def _unique_username(base: str) -> str: an email address or trigger NoSQL injection checks, then appends an incrementing numeric suffix until a free slot is found. """ - base = base.replace("@", "").replace("$", "") + base = base.replace("@", "").replace("$", "").strip() candidate = base counter = 1 while db.users.find_one({"username": candidate}): From 3dd05dce967c7082d7594338626bc288078b5db4 Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Sat, 25 Apr 2026 21:25:51 +0530 Subject: [PATCH 06/24] Update routes/auth.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/auth.py b/routes/auth.py index 5e638be8..2046fb21 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -432,7 +432,7 @@ def google_auth(): # Create a minimal Google-backed user (no local password) now_utc = datetime.now(timezone.utc) - google_username = _unique_username(name or email.split("@")[0]) + google_username = _unique_username((name[:100] if name else None) or email.split("@")[0]) result = db.users.insert_one({ "email": email, "username": google_username, From fa3551019b7fd890fcc6a4a27e77ff9cc033f0c3 Mon Sep 17 00:00:00 2001 From: Prasanna Date: Sat, 25 Apr 2026 21:28:49 +0530 Subject: [PATCH 07/24] fix:Update database config --- database/databaseConfig.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/database/databaseConfig.py b/database/databaseConfig.py index e5b42876..a576c1f2 100644 --- a/database/databaseConfig.py +++ b/database/databaseConfig.py @@ -101,9 +101,19 @@ def initialize_text_index(): logger.info("Compound index created on user_id and created_at in image collection") # Add user collection indexes - if 'username_1' not in existing_user_indexes: - user_collection.create_index([('username', 1)], name='username_1') - logger.info("Index created on username in user collection") + # The username index MUST be unique to prevent race-condition duplicates. + # If an old non-unique index exists, drop it first so we can recreate + # it with unique=True. + if 'username_1' in existing_user_indexes: + if not existing_user_indexes['username_1'].get('unique'): + user_collection.drop_index('username_1') + logger.info("Dropped non-unique username_1 index to replace with unique index") + user_collection.create_index([('username', 1)], name='username_1', unique=True) + logger.info("Unique index created on username in user collection") + # else: already unique — nothing to do + else: + user_collection.create_index([('username', 1)], name='username_1', unique=True) + logger.info("Unique index created on username in user collection") if 'email_1' not in existing_user_indexes: user_collection.create_index([('email', 1)], name='email_1') logger.info("Index created on email in user collection") From 4405df1e4c28c9494c0c575e876a514391940946 Mon Sep 17 00:00:00 2001 From: Prasanna Date: Sat, 25 Apr 2026 21:28:59 +0530 Subject: [PATCH 08/24] feat: implement authentication --- routes/auth.py | 51 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index fd98c17a..03212269 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -4,6 +4,7 @@ import bcrypt from google.oauth2 import id_token from google.auth.transport import requests as google_requests +import pymongo.errors from bson import ObjectId from bson.errors import InvalidId @@ -348,14 +349,21 @@ def set_password(): now_utc = datetime.now(timezone.utc) username = _unique_username(email.split("@")[0]) - user_id = db.users.insert_one({ - "email": email, - "username": username, - "password": hashed, - "role": role, - "created_at": now_utc, - "last_active": now_utc - }).inserted_id + try: + user_id = db.users.insert_one({ + "email": email, + "username": username, + "password": hashed, + "role": role, + "created_at": now_utc, + "last_active": now_utc + }).inserted_id + except pymongo.errors.DuplicateKeyError: + current_app.logger.warning( + "DuplicateKeyError on signup for email=%s username=%s — race condition", + email, username, + ) + return jsonify({"error": "Username already taken. Please try again."}), 409 # Cleanup OTPs db.email_otps.delete_many({"email": email}) @@ -433,16 +441,23 @@ def google_auth(): # Create a minimal Google-backed user (no local password) now_utc = datetime.now(timezone.utc) google_username = _unique_username(name or email.split("@")[0]) - result = db.users.insert_one({ - "email": email, - "username": google_username, - "password": None, - "role": role, - "provider": "google", - "google_id": sub, - "created_at": now_utc, - "last_active": now_utc - }) + try: + result = db.users.insert_one({ + "email": email, + "username": google_username, + "password": None, + "role": role, + "provider": "google", + "google_id": sub, + "created_at": now_utc, + "last_active": now_utc + }) + except pymongo.errors.DuplicateKeyError: + current_app.logger.warning( + "DuplicateKeyError on Google signup for email=%s username=%s — race condition", + email, google_username, + ) + return jsonify({"error": "Username conflict during signup. Please try again."}), 409 user_id = str(result.inserted_id) else: user_id = str(user["_id"]) From 78d7a95e7da75438f20f1a5f0905eba402a10e25 Mon Sep 17 00:00:00 2001 From: Prasanna Date: Sun, 26 Apr 2026 08:48:50 +0530 Subject: [PATCH 09/24] fix: Improve error handling --- routes/auth.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 5cb6f688..31d07fdc 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -222,14 +222,21 @@ def complete_signup(): ) now_utc = datetime.now(timezone.utc) - result = db.users.insert_one({ - "email": email, - "username": username, - "password": hashed_password, - "role": role, - "created_at": now_utc, - "last_active": now_utc - }) + try: + result = db.users.insert_one({ + "email": email, + "username": username, + "password": hashed_password, + "role": role, + "created_at": now_utc, + "last_active": now_utc + }) + except pymongo.errors.DuplicateKeyError: + current_app.logger.warning( + "DuplicateKeyError on complete-signup for email=%s username=%s — race condition", + email, username, + ) + return jsonify({"error": "Username already taken. Please choose a different one."}), 409 db.email_otps.delete_many({"email": email}) token = create_access_token( From 9979575c46d624cf180ad3e97f8bc5b83a92095f Mon Sep 17 00:00:00 2001 From: Prasanna Date: Sun, 26 Apr 2026 08:49:12 +0530 Subject: [PATCH 10/24] feat: implement automated collection indexing utility --- database/databaseConfig.py | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/database/databaseConfig.py b/database/databaseConfig.py index a576c1f2..d7360880 100644 --- a/database/databaseConfig.py +++ b/database/databaseConfig.py @@ -1,6 +1,7 @@ import os from dotenv import find_dotenv, load_dotenv from pymongo import MongoClient, TEXT +from pymongo.errors import DuplicateKeyError as MongoDuplicateKeyError from utils.logger import Logger logger = Logger.get_logger("databaseConfig") @@ -106,14 +107,45 @@ def initialize_text_index(): # it with unique=True. if 'username_1' in existing_user_indexes: if not existing_user_indexes['username_1'].get('unique'): - user_collection.drop_index('username_1') - logger.info("Dropped non-unique username_1 index to replace with unique index") - user_collection.create_index([('username', 1)], name='username_1', unique=True) - logger.info("Unique index created on username in user collection") + # Safe upgrade: create a temporary unique index FIRST to + # detect any duplicate data before we drop anything. + # If duplicates exist, create_index raises DuplicateKeyError + # and the original index is left completely untouched. + try: + user_collection.create_index( + [('username', 1)], + name='username_1_unique_tmp', + unique=True, + ) + # No duplicates confirmed — safe to swap. + user_collection.drop_index('username_1') + user_collection.drop_index('username_1_unique_tmp') + user_collection.create_index( + [('username', 1)], name='username_1', unique=True + ) + logger.info("Upgraded username_1 index to unique=True") + except MongoDuplicateKeyError: + # Temp creation failed; old non-unique index is still in place. + # Clean up the temp index just in case it was partially recorded. + try: + user_collection.drop_index('username_1_unique_tmp') + except Exception: + pass + logger.error( + "Cannot upgrade username index to unique: duplicate usernames " + "exist in the collection. Manual deduplication is required " + "before uniqueness can be enforced." + ) # else: already unique — nothing to do else: - user_collection.create_index([('username', 1)], name='username_1', unique=True) - logger.info("Unique index created on username in user collection") + try: + user_collection.create_index([('username', 1)], name='username_1', unique=True) + logger.info("Unique index created on username in user collection") + except MongoDuplicateKeyError: + logger.error( + "Cannot create unique username index: duplicate usernames exist. " + "Manual deduplication required." + ) if 'email_1' not in existing_user_indexes: user_collection.create_index([('email', 1)], name='email_1') logger.info("Index created on email in user collection") From 911bd20c17e43604a9fd830caa0b4c9104d0610a Mon Sep 17 00:00:00 2001 From: Prasanna Date: Sun, 26 Apr 2026 08:52:31 +0530 Subject: [PATCH 11/24] feat: implement authentication blueprint --- routes/auth.py | 95 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 38 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 31d07fdc..6c31060c 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -27,6 +27,11 @@ OTP_VERIFICATION_WINDOW_SECONDS = 600 # 10 minutes +# Maximum sequential suffix attempts in _unique_username before a random fallback is used. +_MAX_USERNAME_SEQUENTIAL = 10 +# Maximum full retries (generate + insert) for auto-assigned usernames before giving up. +_MAX_SIGNUP_RETRIES = 3 + def _unique_username(base: str) -> str: """Return a username derived from *base* that does not already exist. @@ -34,14 +39,19 @@ def _unique_username(base: str) -> str: Strips any '@' or '$' characters so the result can never be mistaken for an email address or trigger NoSQL injection checks, then appends an incrementing numeric suffix until a free slot is found. + + At most _MAX_USERNAME_SEQUENTIAL sequential candidates are checked; if all + are taken a random 7-digit suffix is used to break the collision cluster + without spinning indefinitely. The DB unique index is the final backstop. """ base = base.replace("@", "").replace("$", "").strip() candidate = base - counter = 1 - while db.users.find_one({"username": candidate}): + for counter in range(1, _MAX_USERNAME_SEQUENTIAL + 1): + if not db.users.find_one({"username": candidate}): + return candidate candidate = f"{base}{counter}" - counter += 1 - return candidate + # Sequential slots all taken — use a random suffix to avoid an unbounded loop. + return f"{base}{secrets.randbelow(9_999_999) + 1}" def _validate_otp_verification(email: str): @@ -355,22 +365,26 @@ def set_password(): role = "admin" if is_admin_email(email) else "user" now_utc = datetime.now(timezone.utc) - username = _unique_username(email.split("@")[0]) - try: - user_id = db.users.insert_one({ - "email": email, - "username": username, - "password": hashed, - "role": role, - "created_at": now_utc, - "last_active": now_utc - }).inserted_id - except pymongo.errors.DuplicateKeyError: - current_app.logger.warning( - "DuplicateKeyError on signup for email=%s username=%s — race condition", - email, username, - ) - return jsonify({"error": "Username already taken. Please try again."}), 409 + user_id = None + for _attempt in range(_MAX_SIGNUP_RETRIES): + username = _unique_username(email.split("@")[0]) + try: + user_id = db.users.insert_one({ + "email": email, + "username": username, + "password": hashed, + "role": role, + "created_at": now_utc, + "last_active": now_utc + }).inserted_id + break # success + except pymongo.errors.DuplicateKeyError: + current_app.logger.warning( + "DuplicateKeyError on signup attempt %d/%d for email=%s username=%s", + _attempt + 1, _MAX_SIGNUP_RETRIES, email, username, + ) + if _attempt == _MAX_SIGNUP_RETRIES - 1: + return jsonify({"error": "Could not assign a unique username. Please try again."}), 409 # Cleanup OTPs db.email_otps.delete_many({"email": email}) @@ -447,24 +461,29 @@ def google_auth(): # Create a minimal Google-backed user (no local password) now_utc = datetime.now(timezone.utc) - google_username = _unique_username(name or email.split("@")[0]) - try: - result = db.users.insert_one({ - "email": email, - "username": google_username, - "password": None, - "role": role, - "provider": "google", - "google_id": sub, - "created_at": now_utc, - "last_active": now_utc - }) - except pymongo.errors.DuplicateKeyError: - current_app.logger.warning( - "DuplicateKeyError on Google signup for email=%s username=%s — race condition", - email, google_username, - ) - return jsonify({"error": "Username conflict during signup. Please try again."}), 409 + base_name = name or email.split("@")[0] + result = None + for _attempt in range(_MAX_SIGNUP_RETRIES): + google_username = _unique_username(base_name) + try: + result = db.users.insert_one({ + "email": email, + "username": google_username, + "password": None, + "role": role, + "provider": "google", + "google_id": sub, + "created_at": now_utc, + "last_active": now_utc + }) + break # success + except pymongo.errors.DuplicateKeyError: + current_app.logger.warning( + "DuplicateKeyError on Google signup attempt %d/%d for email=%s username=%s", + _attempt + 1, _MAX_SIGNUP_RETRIES, email, google_username, + ) + if _attempt == _MAX_SIGNUP_RETRIES - 1: + return jsonify({"error": "Could not assign a unique username. Please try again."}), 409 user_id = str(result.inserted_id) else: user_id = str(user["_id"]) From ab0a449920b11bfcb20dc1c6a8a830e861bf81ff Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Tue, 28 Apr 2026 21:59:59 +0530 Subject: [PATCH 12/24] Update database/databaseConfig.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- database/databaseConfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/databaseConfig.py b/database/databaseConfig.py index d7360880..430d4779 100644 --- a/database/databaseConfig.py +++ b/database/databaseConfig.py @@ -147,7 +147,7 @@ def initialize_text_index(): "Manual deduplication required." ) if 'email_1' not in existing_user_indexes: - user_collection.create_index([('email', 1)], name='email_1') + user_collection.create_index([('email', 1)], name='email_1', unique=True) logger.info("Index created on email in user collection") except Exception as ie: logger.error(f"Error creating collection indexes: {ie}") From 336e1fb10a7a43944a2921dbec6f0c9b6cc93623 Mon Sep 17 00:00:00 2001 From: Prasanna Date: Tue, 28 Apr 2026 22:04:35 +0530 Subject: [PATCH 13/24] feat: implement MongoDB connection configuration --- database/databaseConfig.py | 36 +++++++++++++++++++++++++++++++++--- routes/auth.py | 38 ++++++++++++++++++++++++++++---------- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/database/databaseConfig.py b/database/databaseConfig.py index d7360880..ec88725e 100644 --- a/database/databaseConfig.py +++ b/database/databaseConfig.py @@ -146,9 +146,39 @@ def initialize_text_index(): "Cannot create unique username index: duplicate usernames exist. " "Manual deduplication required." ) - if 'email_1' not in existing_user_indexes: - user_collection.create_index([('email', 1)], name='email_1') - logger.info("Index created on email in user collection") + if 'email_1' in existing_user_indexes: + if not existing_user_indexes['email_1'].get('unique'): + try: + user_collection.create_index( + [('email', 1)], + name='email_1_unique_tmp', + unique=True, + ) + user_collection.drop_index('email_1') + user_collection.drop_index('email_1_unique_tmp') + user_collection.create_index( + [('email', 1)], name='email_1', unique=True + ) + logger.info("Upgraded email_1 index to unique=True") + except MongoDuplicateKeyError: + try: + user_collection.drop_index('email_1_unique_tmp') + except Exception: + pass + logger.error( + "Cannot upgrade email index to unique: duplicate emails " + "exist in the collection. Manual deduplication is required " + "before uniqueness can be enforced." + ) + else: + try: + user_collection.create_index([('email', 1)], name='email_1', unique=True) + logger.info("Unique index created on email in user collection") + except MongoDuplicateKeyError: + logger.error( + "Cannot create unique email index: duplicate emails exist. " + "Manual deduplication required." + ) except Exception as ie: logger.error(f"Error creating collection indexes: {ie}") except Exception as e: diff --git a/routes/auth.py b/routes/auth.py index 6c31060c..2b44dbf7 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -40,18 +40,25 @@ def _unique_username(base: str) -> str: an email address or trigger NoSQL injection checks, then appends an incrementing numeric suffix until a free slot is found. - At most _MAX_USERNAME_SEQUENTIAL sequential candidates are checked; if all - are taken a random 7-digit suffix is used to break the collision cluster - without spinning indefinitely. The DB unique index is the final backstop. + At most _MAX_USERNAME_SEQUENTIAL sequential candidates are checked + (bare *base*, then *base1* … *base_MAX*); if all are taken a hex suffix + is used to break the collision cluster without spinning indefinitely. + The DB unique index is the final backstop against races. """ - base = base.replace("@", "").replace("$", "").strip() + # Default to "user" so an all-symbol prefix (e.g. "@") never yields an + # empty string, which would produce an unusable username. + base = base.replace("@", "").replace("$", "").strip() or "user" candidate = base for counter in range(1, _MAX_USERNAME_SEQUENTIAL + 1): if not db.users.find_one({"username": candidate}): return candidate candidate = f"{base}{counter}" - # Sequential slots all taken — use a random suffix to avoid an unbounded loop. - return f"{base}{secrets.randbelow(9_999_999) + 1}" + # Check the final sequential candidate (base_MAX) before giving up. + if not db.users.find_one({"username": candidate}): + return candidate + # All sequential slots taken — use a hex suffix to minimise collision risk + # (avoids the small-integer overlap that a randbelow(10) fallback would have). + return f"{base}{secrets.token_hex(4)}" def _validate_otp_verification(email: str): @@ -241,13 +248,14 @@ def complete_signup(): "created_at": now_utc, "last_active": now_utc }) - except pymongo.errors.DuplicateKeyError: + except pymongo.errors.DuplicateKeyError as e: current_app.logger.warning( "DuplicateKeyError on complete-signup for email=%s username=%s — race condition", email, username, ) + if "email" in str(e): + return jsonify({"error": "Email already registered"}), 409 return jsonify({"error": "Username already taken. Please choose a different one."}), 409 - db.email_otps.delete_many({"email": email}) token = create_access_token( user_id=str(result.inserted_id), @@ -378,11 +386,13 @@ def set_password(): "last_active": now_utc }).inserted_id break # success - except pymongo.errors.DuplicateKeyError: + except pymongo.errors.DuplicateKeyError as e: current_app.logger.warning( "DuplicateKeyError on signup attempt %d/%d for email=%s username=%s", _attempt + 1, _MAX_SIGNUP_RETRIES, email, username, ) + if "email" in str(e): + return jsonify({"error": "Email already registered"}), 409 if _attempt == _MAX_SIGNUP_RETRIES - 1: return jsonify({"error": "Could not assign a unique username. Please try again."}), 409 @@ -477,11 +487,19 @@ def google_auth(): "last_active": now_utc }) break # success - except pymongo.errors.DuplicateKeyError: + except pymongo.errors.DuplicateKeyError as e: current_app.logger.warning( "DuplicateKeyError on Google signup attempt %d/%d for email=%s username=%s", _attempt + 1, _MAX_SIGNUP_RETRIES, email, google_username, ) + if "email" in str(e): + user = db.users.find_one({"email": email}) + if user: + return jsonify({ + "access_token": create_access_token(user_id=str(user["_id"]), role=user.get("role", "user")), + "role": user.get("role", "user") + }), 200 + return jsonify({"error": "Failed to authenticate"}), 500 if _attempt == _MAX_SIGNUP_RETRIES - 1: return jsonify({"error": "Could not assign a unique username. Please try again."}), 409 user_id = str(result.inserted_id) From 702ff41dc5c2b390552b11bfc6cd8f6cf91cac66 Mon Sep 17 00:00:00 2001 From: Prasanna Date: Tue, 28 Apr 2026 22:13:32 +0530 Subject: [PATCH 14/24] feat: initialize MongoDB configuration with indexing logic --- database/databaseConfig.py | 39 +++++++++++++++++++++++++++++++++++--- routes/auth.py | 6 ++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/database/databaseConfig.py b/database/databaseConfig.py index d7360880..e5d4f5cb 100644 --- a/database/databaseConfig.py +++ b/database/databaseConfig.py @@ -146,9 +146,42 @@ def initialize_text_index(): "Cannot create unique username index: duplicate usernames exist. " "Manual deduplication required." ) - if 'email_1' not in existing_user_indexes: - user_collection.create_index([('email', 1)], name='email_1') - logger.info("Index created on email in user collection") + if 'email_1' in existing_user_indexes: + if not existing_user_indexes['email_1'].get('unique'): + # Safe upgrade: create temp unique index first to catch duplicates + # before touching the existing index. + try: + user_collection.create_index( + [('email', 1)], + name='email_1_unique_tmp', + unique=True, + ) + user_collection.drop_index('email_1') + user_collection.drop_index('email_1_unique_tmp') + user_collection.create_index( + [('email', 1)], name='email_1', unique=True + ) + logger.info("Upgraded email_1 index to unique=True") + except MongoDuplicateKeyError: + try: + user_collection.drop_index('email_1_unique_tmp') + except Exception: + pass + logger.error( + "Cannot upgrade email index to unique: duplicate emails " + "exist in the collection. Manual deduplication is required " + "before uniqueness can be enforced." + ) + # else: already unique — nothing to do + else: + try: + user_collection.create_index([('email', 1)], name='email_1', unique=True) + logger.info("Unique index created on email in user collection") + except MongoDuplicateKeyError: + logger.error( + "Cannot create unique email index: duplicate emails exist. " + "Manual deduplication required." + ) except Exception as ie: logger.error(f"Error creating collection indexes: {ie}") except Exception as e: diff --git a/routes/auth.py b/routes/auth.py index 061ec0d0..1c054405 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -396,8 +396,6 @@ def set_password(): current_app.logger.warning("SET PASSWORD VALIDATION ERROR") return jsonify({"error": str(e)}), 400 - if purpose not in ("signup", "reset"): - return jsonify({"error": "Invalid purpose. Must be 'signup' or 'reset'."}), 400 if len(password) < 8: return jsonify({"error": "Password must be at least 8 characters"}), 400 @@ -441,6 +439,10 @@ def set_password(): if _attempt == _MAX_SIGNUP_RETRIES - 1: return jsonify({"error": "Could not assign a unique username. Please try again."}), 409 + if user_id is None: + # All retry attempts exhausted without a successful insert. + return jsonify({"error": "Could not assign a unique username. Please try again."}), 409 + # Cleanup OTPs db.email_otps.delete_many({"email": email}) From f854954d83f406e2041e5125870ca8bc46551d51 Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Tue, 28 Apr 2026 22:17:08 +0530 Subject: [PATCH 15/24] Update routes/auth.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/auth.py b/routes/auth.py index 1c054405..90aaefbb 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -298,7 +298,7 @@ def complete_signup(): "DuplicateKeyError on complete-signup for email=%s username=%s — race condition", email, username, ) - if "email" in str(e): + if e.details and e.details.get("keyPattern", {}).get("email"): return jsonify({"error": "Email already registered"}), 409 return jsonify({"error": "Username already taken. Please choose a different one."}), 409 From 4fb550cd3b1a575b211a37c7a564e4349464c7e4 Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Tue, 28 Apr 2026 22:18:07 +0530 Subject: [PATCH 16/24] Update routes/auth.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/auth.py b/routes/auth.py index 90aaefbb..1f60b64b 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -434,7 +434,7 @@ def set_password(): "DuplicateKeyError on signup attempt %d/%d for email=%s username=%s", _attempt + 1, _MAX_SIGNUP_RETRIES, email, username, ) - if "email" in str(e): + if e.details and e.details.get("keyPattern", {}).get("email"): return jsonify({"error": "Email already registered"}), 409 if _attempt == _MAX_SIGNUP_RETRIES - 1: return jsonify({"error": "Could not assign a unique username. Please try again."}), 409 From 68dd3de7d686c8bd1fa21e7cd1e5144c613c5c9a Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Tue, 28 Apr 2026 22:18:20 +0530 Subject: [PATCH 17/24] Update routes/auth.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/auth.py b/routes/auth.py index 1f60b64b..5b61d930 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -539,7 +539,7 @@ def google_auth(): "DuplicateKeyError on Google signup attempt %d/%d for email=%s username=%s", _attempt + 1, _MAX_SIGNUP_RETRIES, email, google_username, ) - if "email" in str(e): + if e.details and e.details.get("keyPattern", {}).get("email"): user = db.users.find_one({"email": email}) if user: return jsonify({ From 7ba1470740d4507103a98cfe6cb09e3af5fd3c34 Mon Sep 17 00:00:00 2001 From: Prasanna Date: Tue, 28 Apr 2026 22:20:33 +0530 Subject: [PATCH 18/24] feat: add auth blueprint with OTP request, verification, and registration endpoints --- routes/auth.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 5b61d930..8884f48a 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -439,9 +439,6 @@ def set_password(): if _attempt == _MAX_SIGNUP_RETRIES - 1: return jsonify({"error": "Could not assign a unique username. Please try again."}), 409 - if user_id is None: - # All retry attempts exhausted without a successful insert. - return jsonify({"error": "Could not assign a unique username. Please try again."}), 409 # Cleanup OTPs db.email_otps.delete_many({"email": email}) From 275dfc643dbfcec924f7e3140d04abe62e73a394 Mon Sep 17 00:00:00 2001 From: Prasanna Date: Tue, 28 Apr 2026 22:32:20 +0530 Subject: [PATCH 19/24] feat: implement authentication routes --- routes/auth.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 8884f48a..0cbdc46a 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -1,5 +1,6 @@ from flask import Blueprint, request, jsonify, current_app from datetime import datetime, timedelta, timezone +import re import secrets import bcrypt from google.oauth2 import id_token @@ -43,24 +44,34 @@ def _unique_username(base: str) -> str: an email address or trigger NoSQL injection checks, then appends an incrementing numeric suffix until a free slot is found. - At most _MAX_USERNAME_SEQUENTIAL sequential candidates are checked - (bare *base*, then *base1* … *base_MAX*); if all are taken a hex suffix - is used to break the collision cluster without spinning indefinitely. - The DB unique index is the final backstop against races. + A single regex query fetches all taken variants (base, base1 … baseN) so + the suffix search is done in-memory without extra round-trips. Falls back + to a hex suffix if every sequential slot is occupied. The DB unique index + is the final backstop against races. """ # Default to "user" so an all-symbol prefix (e.g. "@") never yields an # empty string, which would produce an unusable username. base = base.replace("@", "").replace("$", "").strip() or "user" - candidate = base + + # Fetch every username that matches base or base. + pattern = f"^{re.escape(base)}[0-9]*$" + taken = { + doc["username"] + for doc in db.users.find( + {"username": {"$regex": pattern}}, + {"username": 1, "_id": 0}, + ) + } + + # Try the bare base first, then base1 … base_MAX in-memory. + if base not in taken: + return base for counter in range(1, _MAX_USERNAME_SEQUENTIAL + 1): - if not db.users.find_one({"username": candidate}): - return candidate candidate = f"{base}{counter}" - # Check the final sequential candidate (base_MAX) before giving up. - if not db.users.find_one({"username": candidate}): - return candidate - # All sequential slots taken — use a hex suffix to minimise collision risk - # (avoids the small-integer overlap that a randbelow(10) fallback would have). + if candidate not in taken: + return candidate + + # All sequential slots occupied — hex suffix to minimise collision risk. return f"{base}{secrets.token_hex(4)}" From 69ab6e26e632c7dd63f883a00c8abc57ea24b2a4 Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Wed, 29 Apr 2026 18:50:37 +0530 Subject: [PATCH 20/24] Update database/databaseConfig.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- database/databaseConfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/databaseConfig.py b/database/databaseConfig.py index e5d4f5cb..9ab01fb4 100644 --- a/database/databaseConfig.py +++ b/database/databaseConfig.py @@ -119,10 +119,10 @@ def initialize_text_index(): ) # No duplicates confirmed — safe to swap. user_collection.drop_index('username_1') - user_collection.drop_index('username_1_unique_tmp') user_collection.create_index( [('username', 1)], name='username_1', unique=True ) + user_collection.drop_index('username_1_unique_tmp') logger.info("Upgraded username_1 index to unique=True") except MongoDuplicateKeyError: # Temp creation failed; old non-unique index is still in place. From 81c04b0168f6206c5b983ec922f884a1416f256d Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Wed, 29 Apr 2026 18:50:53 +0530 Subject: [PATCH 21/24] Update routes/auth.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- routes/auth.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 0cbdc46a..e3424660 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -54,11 +54,13 @@ def _unique_username(base: str) -> str: base = base.replace("@", "").replace("$", "").strip() or "user" # Fetch every username that matches base or base. - pattern = f"^{re.escape(base)}[0-9]*$" + # Define the specific candidates we want to check in-memory. + candidates = [base] + [f"{base}{i}" for i in range(1, _MAX_USERNAME_SEQUENTIAL + 1)] + taken = { doc["username"] for doc in db.users.find( - {"username": {"$regex": pattern}}, + {"username": {"$in": candidates}}, {"username": 1, "_id": 0}, ) } From c6f7948b27a64bb24a22c9d3da0db8b3d3fa8c67 Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Wed, 29 Apr 2026 18:51:31 +0530 Subject: [PATCH 22/24] Update database/databaseConfig.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- database/databaseConfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/databaseConfig.py b/database/databaseConfig.py index 9ab01fb4..20bb7e4e 100644 --- a/database/databaseConfig.py +++ b/database/databaseConfig.py @@ -157,10 +157,10 @@ def initialize_text_index(): unique=True, ) user_collection.drop_index('email_1') - user_collection.drop_index('email_1_unique_tmp') user_collection.create_index( [('email', 1)], name='email_1', unique=True ) + user_collection.drop_index('email_1_unique_tmp') logger.info("Upgraded email_1 index to unique=True") except MongoDuplicateKeyError: try: From 9b406808aafa2e1cba592bd7a2b8746a457061de Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Wed, 29 Apr 2026 20:37:05 +0530 Subject: [PATCH 23/24] Update routes/auth.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/auth.py b/routes/auth.py index e3424660..c4a12d0b 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -44,7 +44,7 @@ def _unique_username(base: str) -> str: an email address or trigger NoSQL injection checks, then appends an incrementing numeric suffix until a free slot is found. - A single regex query fetches all taken variants (base, base1 … baseN) so + A single query using $in fetches all taken variants (base, base1 … baseN) so the suffix search is done in-memory without extra round-trips. Falls back to a hex suffix if every sequential slot is occupied. The DB unique index is the final backstop against races. From 3a3ec12a3b30876f8755c4b9de38b8791fe2505e Mon Sep 17 00:00:00 2001 From: Prasannakumar Badiger Date: Wed, 29 Apr 2026 20:37:25 +0530 Subject: [PATCH 24/24] Update tests/test_set_password.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tests/test_set_password.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_set_password.py b/tests/test_set_password.py index 9f28018d..de4320e1 100644 --- a/tests/test_set_password.py +++ b/tests/test_set_password.py @@ -239,9 +239,9 @@ def test_unique_username_increments_until_free(mock_db): from routes.auth import _unique_username # "carol" and "carol1" are taken; "carol2" is free - mock_db.users.find_one.side_effect = [ - {"_id": "1"}, # "carol" taken - {"_id": "2"}, # "carol1" taken - None, # "carol2" free + # "carol" and "carol1" are taken; "carol2" is free + mock_db.users.find.return_value = [ + {"username": "carol"}, + {"username": "carol1"}, ] assert _unique_username("carol") == "carol2"