From 6d1023541a817f2d50bf24607420fe851ec17f91 Mon Sep 17 00:00:00 2001 From: Julien Bouquiaux Date: Thu, 4 Jun 2026 10:52:52 +0200 Subject: [PATCH 1/6] Add development environment setup with Docker Compose and example configuration files --- pydatalab/tasks.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/pydatalab/tasks.py b/pydatalab/tasks.py index 21a364baf..9c032e0be 100644 --- a/pydatalab/tasks.py +++ b/pydatalab/tasks.py @@ -165,6 +165,84 @@ def serve(_, host: str = "127.0.0.1", port: int = 5001, reload: bool = True, tes dev.add_task(serve) +@task( + help={ + "username": "Local testing username", + "password": "Local testing password", + "display_name": "Display name for a newly-created or updated user", + "contact_email": "Optional contact email for the user", + "role": "User role: user, manager, or admin", + "account_status": "Account status: active, unverified, or deactivated", + } +) +def create_local_user( + _, + username: str, + password: str, + display_name: str | None = None, + contact_email: str | None = None, + role: str = "user", + account_status: str = "active", +): + """Create or update a testing-only local username/password user.""" + from bson import ObjectId + from pydantic import ValidationError + + from pydatalab.config import CONFIG + from pydatalab.local_auth import load_local_credentials, set_local_credential + from pydatalab.models.people import AccountStatus, Person + from pydatalab.models.utils import HumanReadableIdentifier, UserRole + from pydatalab.mongo import get_database + + if not CONFIG.TESTING: + raise SystemExit( + "Local username/password users are only available when CONFIG.TESTING is true. " + "Set PYDATALAB_TESTING=1 before running this task." + ) + + try: + username = str(HumanReadableIdentifier(username)) + except ValidationError as exc: + raise SystemExit(f"Invalid username {username!r}: {exc}") from None + try: + role_value = UserRole(role) + except ValueError: + raise SystemExit(f"Invalid role {role!r}. Must be one of {[r.value for r in UserRole]}.") + + try: + status_value = AccountStatus(account_status) + except ValueError: + raise SystemExit( + f"Invalid account status {account_status!r}. " + f"Must be one of {[s.value for s in AccountStatus]}." + ) + + db = get_database() + credentials = load_local_credentials() + existing_credential = credentials.get(username) + user_id = ObjectId(existing_credential["user_id"]) if existing_credential else ObjectId() + existing_user = db.users.find_one({"_id": user_id}) + + user_update = {"account_status": status_value.value} + if display_name is not None or existing_user is None: + user_update["display_name"] = display_name or username + if contact_email is not None: + user_update["contact_email"] = contact_email + + if existing_user is None: + user = Person(immutable_id=user_id, **user_update) + db.users.insert_one(user.dict(by_alias=True, exclude_none=True)) + else: + db.users.update_one({"_id": user_id}, {"$set": user_update}) + + db.roles.update_one({"_id": user_id}, {"$set": {"role": role_value.value}}, upsert=True) + set_local_credential(username, str(user_id), password) + print(f"Local testing user {username!r} is linked to {user_id}.") + + +dev.add_task(create_local_user) + + # Example data for `dev.seed`, grouped by the block that renders it. Each group # is expanded by `_build_seed_items` into one seeded item per file (a single # block per item), so a fresh dev instance exercises every block type that has From 4bf37cc02614df316df7163af22928e6322fe97f Mon Sep 17 00:00:00 2001 From: David Waroquiers Date: Sat, 20 Jun 2026 11:42:19 +0200 Subject: [PATCH 2/6] Local username/password credentials for testing purposes. --- pydatalab/src/pydatalab/feature_flags.py | 3 + pydatalab/src/pydatalab/local_auth.py | 67 ++++++++++ pydatalab/src/pydatalab/routes/v0_1/auth.py | 31 ++++- pydatalab/tasks.py | 79 +++++++++++ pydatalab/tests/server/test_auth.py | 110 ++++++++++++++++ .../tests/server/test_info_and_health.py | 1 + webapp/src/components/LoginDetails.vue | 8 +- webapp/src/components/LoginDropdown.vue | 124 ++++++++++++++++++ webapp/src/server_fetch_utils.js | 6 + 9 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 pydatalab/src/pydatalab/local_auth.py diff --git a/pydatalab/src/pydatalab/feature_flags.py b/pydatalab/src/pydatalab/feature_flags.py index 8100b55c9..886fb6c29 100644 --- a/pydatalab/src/pydatalab/feature_flags.py +++ b/pydatalab/src/pydatalab/feature_flags.py @@ -16,6 +16,8 @@ class AuthMechanisms(BaseModel): email: bool = False google: bool = False microsoft: bool = False + # Testing-only username/password login, never intended as a production auth mechanism. + testing_local: bool = False class AIIntegrations(BaseModel): @@ -58,6 +60,7 @@ def check_feature_flags(app): object reported by the API for use in UIs. """ + FEATURE_FLAGS.auth_mechanisms.testing_local = bool(CONFIG.TESTING) if CONFIG.EMAIL_AUTH_SMTP_SETTINGS is None: LOGGER.warning( diff --git a/pydatalab/src/pydatalab/local_auth.py b/pydatalab/src/pydatalab/local_auth.py new file mode 100644 index 000000000..865e0648d --- /dev/null +++ b/pydatalab/src/pydatalab/local_auth.py @@ -0,0 +1,67 @@ +"""Small file-backed local credentials for testing-only login.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from pydantic import ValidationError +from werkzeug.security import check_password_hash, generate_password_hash + +from pydatalab.config import CONFIG +from pydatalab.models.utils import HumanReadableIdentifier + +LOCAL_AUTH_FILENAME = ".local_auth_credentials.json" + + +def local_auth_credentials_path() -> Path: + return Path(CONFIG.FILE_DIRECTORY) / LOCAL_AUTH_FILENAME + + +def load_local_credentials() -> dict: + path = local_auth_credentials_path() + if not path.is_file(): + return {} + + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError: + return {} + + return data if isinstance(data, dict) else {} + + +def save_local_credentials(credentials: dict) -> None: + path = local_auth_credentials_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text(json.dumps(credentials, indent=2, sort_keys=True) + "\n") + os.replace(tmp_path, path) + + +def set_local_credential(username: str, user_id: str, password: str) -> None: + username = str(HumanReadableIdentifier(username)) + credentials = load_local_credentials() + credentials[username] = { + "user_id": str(user_id), + "password_hash": generate_password_hash(password), + } + save_local_credentials(credentials) + + +def verify_local_credential(username: str, password: str) -> str | None: + try: + username = str(HumanReadableIdentifier(username)) + except (ValueError, ValidationError): + return None + + credential = load_local_credentials().get(username) + if not credential: + return None + + password_hash = credential.get("password_hash") + if not password_hash or not check_password_hash(password_hash, password): + return None + + return credential.get("user_id") diff --git a/pydatalab/src/pydatalab/routes/v0_1/auth.py b/pydatalab/src/pydatalab/routes/v0_1/auth.py index 549d96f99..84a4e61d4 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/auth.py +++ b/pydatalab/src/pydatalab/routes/v0_1/auth.py @@ -18,11 +18,12 @@ from flask_dance.consumer import OAuth2ConsumerBlueprint, oauth_authorized from flask_login import current_user, login_user from flask_login.utils import LocalProxy -from werkzeug.exceptions import BadRequest, Forbidden +from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized from pydatalab.config import CONFIG from pydatalab.errors import UserRegistrationForbidden from pydatalab.feature_flags import FEATURE_FLAGS +from pydatalab.local_auth import verify_local_credential from pydatalab.logger import LOGGER from pydatalab.login import get_by_id from pydatalab.models.people import AccountStatus, Identity, IdentityType, Person @@ -797,6 +798,34 @@ def generate_and_share_magic_link(): return jsonify({"status": "success", "message": "Email sent successfully."}), 200 +@EMAIL_BLUEPRINT.route("/local", methods=["POST"]) +def local_login(): + """Testing-only username/password login.""" + if not CONFIG.TESTING: + raise Forbidden("Local login is only available in testing mode.") + + request_json = request.get_json() or {} + username = request_json.get("username") + password = request_json.get("password") + if not username or not password: + raise Unauthorized("Invalid username or password.") + + user_id = verify_local_credential(username, password) + if not user_id: + raise Unauthorized("Invalid username or password.") + + try: + user_model = get_by_id(str(user_id)) + except (StopIteration, ValueError): + user_model = None + + if user_model is None or user_model.account_status == AccountStatus.DEACTIVATED: + raise Unauthorized("Invalid username or password.") + + wrapped_login_user(user_model) + return jsonify({"status": "success"}), 200 + + @EMAIL_BLUEPRINT.route("/email") def email_logged_in(): """Endpoint for handling magic link authentication. diff --git a/pydatalab/tasks.py b/pydatalab/tasks.py index 9c032e0be..145046155 100644 --- a/pydatalab/tasks.py +++ b/pydatalab/tasks.py @@ -165,6 +165,7 @@ def serve(_, host: str = "127.0.0.1", port: int = 5001, reload: bool = True, tes dev.add_task(serve) +<<<<<<< HEAD @task( help={ "username": "Local testing username", @@ -572,6 +573,84 @@ def seed(_): dev.add_task(seed) +======= +@task( + help={ + "username": "Local testing username", + "password": "Local testing password", + "display_name": "Display name for a newly-created or updated user", + "contact_email": "Optional contact email for the user", + "role": "User role: user, manager, or admin", + "account_status": "Account status: active, unverified, or deactivated", + } +) +def create_local_user( + _, + username: str, + password: str, + display_name: str | None = None, + contact_email: str | None = None, + role: str = "user", + account_status: str = "active", +): + """Create or update a testing-only local username/password user.""" + from bson import ObjectId + from pydantic import ValidationError + + from pydatalab.config import CONFIG + from pydatalab.local_auth import load_local_credentials, set_local_credential + from pydatalab.models.people import AccountStatus, Person + from pydatalab.models.utils import HumanReadableIdentifier, UserRole + from pydatalab.mongo import get_database + + if not CONFIG.TESTING: + raise SystemExit( + "Local username/password users are only available when CONFIG.TESTING is true. " + "Set PYDATALAB_TESTING=1 before running this task." + ) + + try: + username = str(HumanReadableIdentifier(username)) + except ValidationError as exc: + raise SystemExit(f"Invalid username {username!r}: {exc}") from None + try: + role_value = UserRole(role) + except ValueError: + raise SystemExit(f"Invalid role {role!r}. Must be one of {[r.value for r in UserRole]}.") + + try: + status_value = AccountStatus(account_status) + except ValueError: + raise SystemExit( + f"Invalid account status {account_status!r}. " + f"Must be one of {[s.value for s in AccountStatus]}." + ) + + db = get_database() + credentials = load_local_credentials() + existing_credential = credentials.get(username) + user_id = ObjectId(existing_credential["user_id"]) if existing_credential else ObjectId() + existing_user = db.users.find_one({"_id": user_id}) + + user_update = {"account_status": status_value.value} + if display_name is not None or existing_user is None: + user_update["display_name"] = display_name or username + if contact_email is not None: + user_update["contact_email"] = contact_email + + if existing_user is None: + user = Person(immutable_id=user_id, **user_update) + db.users.insert_one(user.dict(by_alias=True, exclude_none=True)) + else: + db.users.update_one({"_id": user_id}, {"$set": user_update}) + + db.roles.update_one({"_id": user_id}, {"$set": {"role": role_value.value}}, upsert=True) + set_local_credential(username, str(user_id), password) + print(f"Local testing user {username!r} is linked to {user_id}.") + + +dev.add_task(create_local_user) +>>>>>>> abd587d (Local username/password credentials for testing purposes.) @task diff --git a/pydatalab/tests/server/test_auth.py b/pydatalab/tests/server/test_auth.py index 092d564af..8c0822269 100644 --- a/pydatalab/tests/server/test_auth.py +++ b/pydatalab/tests/server/test_auth.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock +from bson import ObjectId + from pydatalab.routes.v0_1.auth import _check_email_domain @@ -62,6 +64,114 @@ def test_magic_links_expected_failures(unauthenticated_client, app): assert len(outbox) == 0 +def test_local_login_disabled_outside_testing(unauthenticated_client): + response = unauthenticated_client.post( + "/login/local", json={"username": "test-user", "password": "password"} + ) + assert response.status_code == 403 + + +def test_local_login_success(unauthenticated_client, monkeypatch, user_id): + from pydatalab.config import CONFIG + from pydatalab.local_auth import set_local_credential + + monkeypatch.setattr(CONFIG, "TESTING", True) + set_local_credential("local-user", str(user_id), "password") + + response = unauthenticated_client.post( + "/login/local", json={"username": "local-user", "password": "password"} + ) + assert response.status_code == 200 + assert response.json["status"] == "success" + + current_user = unauthenticated_client.get("/get-current-user/") + assert current_user.status_code == 200 + assert current_user.json["immutable_id"] == str(user_id) + + +def test_local_login_bad_credentials(unauthenticated_client, monkeypatch, user_id): + from pydatalab.config import CONFIG + from pydatalab.local_auth import set_local_credential + + monkeypatch.setattr(CONFIG, "TESTING", True) + set_local_credential("wrong-password-user", str(user_id), "password") + + response = unauthenticated_client.post( + "/login/local", json={"username": "wrong-password-user", "password": "bad"} + ) + assert response.status_code == 401 + + response = unauthenticated_client.post( + "/login/local", json={"username": "unknown-user", "password": "password"} + ) + assert response.status_code == 401 + + +def test_local_login_missing_linked_user(unauthenticated_client, monkeypatch): + from pydatalab.config import CONFIG + from pydatalab.local_auth import set_local_credential + + monkeypatch.setattr(CONFIG, "TESTING", True) + set_local_credential("missing-user", str(ObjectId()), "password") + + response = unauthenticated_client.post( + "/login/local", json={"username": "missing-user", "password": "password"} + ) + assert response.status_code == 401 + + +def test_local_login_deactivated_user(unauthenticated_client, monkeypatch, deactivated_user_id): + from pydatalab.config import CONFIG + from pydatalab.local_auth import set_local_credential + + monkeypatch.setattr(CONFIG, "TESTING", True) + set_local_credential("deactivated-local-user", str(deactivated_user_id), "password") + + response = unauthenticated_client.post( + "/login/local", json={"username": "deactivated-local-user", "password": "password"} + ) + assert response.status_code == 401 + + +def test_create_local_user_task(database, unauthenticated_client, monkeypatch): + import importlib.util + from pathlib import Path + + from pydatalab.config import CONFIG + from pydatalab.local_auth import load_local_credentials + + tasks_path = Path(__file__).parents[2] / "tasks.py" + spec = importlib.util.spec_from_file_location("pydatalab_tasks", tasks_path) + tasks = importlib.util.module_from_spec(spec) + spec.loader.exec_module(tasks) + + monkeypatch.setattr(CONFIG, "TESTING", True) + + tasks.create_local_user.body( + None, + username="task-local-user", + password="password", # noqa: S106 - this feature is for dev testing only + display_name="Task Local User", + contact_email="task-local-user@example.org", + role="admin", + account_status="active", + ) + + credentials = load_local_credentials() + assert "task-local-user" in credentials + + user_id = ObjectId(credentials["task-local-user"]["user_id"]) + user = database.users.find_one({"_id": user_id}) + assert user["display_name"] == "Task Local User" + assert user["contact_email"] == "task-local-user@example.org" + assert database.roles.find_one({"_id": user_id})["role"] == "admin" + + response = unauthenticated_client.post( + "/login/local", json={"username": "task-local-user", "password": "password"} + ) + assert response.status_code == 200 + + # ────────────────────────────────────────────── # GitHub OAuth tests # ────────────────────────────────────────────── diff --git a/pydatalab/tests/server/test_info_and_health.py b/pydatalab/tests/server/test_info_and_health.py index 70307da17..a1b64e29d 100644 --- a/pydatalab/tests/server/test_info_and_health.py +++ b/pydatalab/tests/server/test_info_and_health.py @@ -19,6 +19,7 @@ def test_info_endpoint(client, url_prefix, app): and app.config.get("ORCID_OAUTH_CLIENT_SECRET", None) ) assert auth["email"] is bool(app.config.get("MAIL_PASSWORD", None)) + assert auth["testing_local"] is False def test_landing_page(unauthenticated_client, client): diff --git a/webapp/src/components/LoginDetails.vue b/webapp/src/components/LoginDetails.vue index 14d64a1a3..62a3066a1 100644 --- a/webapp/src/components/LoginDetails.vue +++ b/webapp/src/components/LoginDetails.vue @@ -59,7 +59,7 @@ style="display: block" aria-labelledby="loginButton" > - + @@ -132,6 +132,12 @@ export default { ); } }, + handleLoginSuccess(user) { + this.user = user; + this.isUserLoaded = true; + this.isLoginDropdownVisible = false; + this.isUserDropdownVisible = false; + }, }, }; diff --git a/webapp/src/components/LoginDropdown.vue b/webapp/src/components/LoginDropdown.vue index b2aaf14d6..6c5861022 100644 --- a/webapp/src/components/LoginDropdown.vue +++ b/webapp/src/components/LoginDropdown.vue @@ -1,5 +1,59 @@ @@ -98,4 +191,35 @@ export default { .orcid-icon { color: #a6ce39; } + +.local-login-button { + white-space: normal; +} + +.local-warning-icon { + color: #b00020; +} + +.local-testing-label { + color: #b00020; + font-size: 0.8rem; + font-weight: 600; + margin-left: 0.25rem; +} + +.local-warning-copy { + border-color: #b00020; +} + +.local-testing-note { + color: #b00020; + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.local-login-error { + color: #b00020; + font-size: 0.8rem; +} diff --git a/webapp/src/server_fetch_utils.js b/webapp/src/server_fetch_utils.js index f52ced172..0cc1817da 100644 --- a/webapp/src/server_fetch_utils.js +++ b/webapp/src/server_fetch_utils.js @@ -643,6 +643,12 @@ export async function requestMagicLink(email_address) { }); } +export async function loginLocal(username, password) { + await fetch_post(`${API_URL}/login/local`, { username, password }); + invalidateCurrentUserCache(); + return getCurrentUser(); +} + export function searchUsers(query, nresults = 100) { // construct a url with parameters: var url = new URL(`${API_URL}/search-users/`); From 52b255498e35682f8e04be24d1dfa45b01ebe5b4 Mon Sep 17 00:00:00 2001 From: David Waroquiers Date: Sat, 20 Jun 2026 12:35:05 +0200 Subject: [PATCH 3/6] Fixing... --- pydatalab/tasks.py | 79 ---------------------------------------------- 1 file changed, 79 deletions(-) diff --git a/pydatalab/tasks.py b/pydatalab/tasks.py index 145046155..9c032e0be 100644 --- a/pydatalab/tasks.py +++ b/pydatalab/tasks.py @@ -165,7 +165,6 @@ def serve(_, host: str = "127.0.0.1", port: int = 5001, reload: bool = True, tes dev.add_task(serve) -<<<<<<< HEAD @task( help={ "username": "Local testing username", @@ -573,84 +572,6 @@ def seed(_): dev.add_task(seed) -======= -@task( - help={ - "username": "Local testing username", - "password": "Local testing password", - "display_name": "Display name for a newly-created or updated user", - "contact_email": "Optional contact email for the user", - "role": "User role: user, manager, or admin", - "account_status": "Account status: active, unverified, or deactivated", - } -) -def create_local_user( - _, - username: str, - password: str, - display_name: str | None = None, - contact_email: str | None = None, - role: str = "user", - account_status: str = "active", -): - """Create or update a testing-only local username/password user.""" - from bson import ObjectId - from pydantic import ValidationError - - from pydatalab.config import CONFIG - from pydatalab.local_auth import load_local_credentials, set_local_credential - from pydatalab.models.people import AccountStatus, Person - from pydatalab.models.utils import HumanReadableIdentifier, UserRole - from pydatalab.mongo import get_database - - if not CONFIG.TESTING: - raise SystemExit( - "Local username/password users are only available when CONFIG.TESTING is true. " - "Set PYDATALAB_TESTING=1 before running this task." - ) - - try: - username = str(HumanReadableIdentifier(username)) - except ValidationError as exc: - raise SystemExit(f"Invalid username {username!r}: {exc}") from None - try: - role_value = UserRole(role) - except ValueError: - raise SystemExit(f"Invalid role {role!r}. Must be one of {[r.value for r in UserRole]}.") - - try: - status_value = AccountStatus(account_status) - except ValueError: - raise SystemExit( - f"Invalid account status {account_status!r}. " - f"Must be one of {[s.value for s in AccountStatus]}." - ) - - db = get_database() - credentials = load_local_credentials() - existing_credential = credentials.get(username) - user_id = ObjectId(existing_credential["user_id"]) if existing_credential else ObjectId() - existing_user = db.users.find_one({"_id": user_id}) - - user_update = {"account_status": status_value.value} - if display_name is not None or existing_user is None: - user_update["display_name"] = display_name or username - if contact_email is not None: - user_update["contact_email"] = contact_email - - if existing_user is None: - user = Person(immutable_id=user_id, **user_update) - db.users.insert_one(user.dict(by_alias=True, exclude_none=True)) - else: - db.users.update_one({"_id": user_id}, {"$set": user_update}) - - db.roles.update_one({"_id": user_id}, {"$set": {"role": role_value.value}}, upsert=True) - set_local_credential(username, str(user_id), password) - print(f"Local testing user {username!r} is linked to {user_id}.") - - -dev.add_task(create_local_user) ->>>>>>> abd587d (Local username/password credentials for testing purposes.) @task From b44f6edb69bf9baf6158c328252d3c201852f52b Mon Sep 17 00:00:00 2001 From: David Waroquiers Date: Sat, 20 Jun 2026 14:53:29 +0200 Subject: [PATCH 4/6] Some cleaning in vue components for username/password login. Moved scoped styles to the components in the template itself as this is a one-off feature only for testing purposes and styles are not expected to be reused elsewhere. --- webapp/src/components/LoginDetails.vue | 4 +- webapp/src/components/LoginDropdown.vue | 158 ++++++++++-------------- 2 files changed, 68 insertions(+), 94 deletions(-) diff --git a/webapp/src/components/LoginDetails.vue b/webapp/src/components/LoginDetails.vue index 62a3066a1..952981ef3 100644 --- a/webapp/src/components/LoginDetails.vue +++ b/webapp/src/components/LoginDetails.vue @@ -59,7 +59,7 @@ style="display: block" aria-labelledby="loginButton" > - + @@ -132,7 +132,7 @@ export default { ); } }, - handleLoginSuccess(user) { + handleCurrentUserChanged(user) { this.user = user; this.isUserLoaded = true; this.isLoginDropdownVisible = false; diff --git a/webapp/src/components/LoginDropdown.vue b/webapp/src/components/LoginDropdown.vue index 6c5861022..724d9c1ae 100644 --- a/webapp/src/components/LoginDropdown.vue +++ b/webapp/src/components/LoginDropdown.vue @@ -1,59 +1,5 @@