diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index acbe9f0a4..9ccd34c60 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -21,7 +21,7 @@ service: `docker compose --profile dev restart api-dev` (or `app-dev`). ## Logging in -The web app needs a logged-in session. Set up GitHub OAuth once: +The web app needs a logged-in session. For a realistic login flow, set up GitHub OAuth once: 1. Create an OAuth App at https://github.com/settings/developers - Homepage URL: `http://localhost:8081` @@ -32,6 +32,20 @@ The web app needs a logged-in session. Set up GitHub OAuth once: `python3 -c 'import secrets; print(secrets.token_hex(64))'`). 4. **Login via GitHub** in the web app. +For local development and testing, you can instead create a testing-only +username/password user. This only works when `PYDATALAB_TESTING=true`; the route and +feature flag are disabled otherwise. + +```bash +docker compose exec api-dev /opt/.venv/bin/invoke dev.create-test-user \ + --username alice \ + --password alice \ + --display-name "Alice" +``` + +Then open the web app, click **Login/Register**, and use the testing +username/password login. + ## Handy commands diff --git a/pydatalab/src/pydatalab/feature_flags.py b/pydatalab/src/pydatalab/feature_flags.py index 8100b55c9..03e3bcf2c 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_username_password: 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_username_password = bool(CONFIG.TESTING) if CONFIG.EMAIL_AUTH_SMTP_SETTINGS is None: LOGGER.warning( diff --git a/pydatalab/src/pydatalab/main.py b/pydatalab/src/pydatalab/main.py index d8ae01bba..c51f41ee9 100644 --- a/pydatalab/src/pydatalab/main.py +++ b/pydatalab/src/pydatalab/main.py @@ -220,7 +220,7 @@ def provider_label(identity_type) -> str: def register_endpoints(app: Flask): """Loops through the implemented endpoints, blueprints and error handlers adds them to the app.""" from pydatalab.errors import ERROR_HANDLERS - from pydatalab.routes import BLUEPRINTS, OAUTH, __api_version__ + from pydatalab.routes import BLUEPRINTS, __api_version__, get_login_blueprints major, minor, patch = __api_version__.split(".") versions = ["", f"v{major}", f"v{major}.{minor}", f"v{major}.{minor}.{patch}"] @@ -231,8 +231,8 @@ def register_endpoints(app: Flask): bp, url_prefix=f"{CONFIG.ROOT_PATH}{ver}", name=f"{ver}/{bp.name}" ) - for bp in OAUTH: # type: ignore - app.register_blueprint(OAUTH[bp], url_prefix=f"{CONFIG.ROOT_PATH}login") # type: ignore + for bp in get_login_blueprints(): + app.register_blueprint(bp, url_prefix=f"{CONFIG.ROOT_PATH}login") for exception_type, handler in ERROR_HANDLERS: app.register_error_handler(exception_type, handler) diff --git a/pydatalab/src/pydatalab/routes/__init__.py b/pydatalab/src/pydatalab/routes/__init__.py index c4f60f90c..fcce6fc71 100644 --- a/pydatalab/src/pydatalab/routes/__init__.py +++ b/pydatalab/src/pydatalab/routes/__init__.py @@ -1,3 +1,16 @@ -from pydatalab.routes.v0_1 import BLUEPRINTS, OAUTH, OAUTH_PROXIES, __api_version__ +from pydatalab.routes.v0_1 import ( + BLUEPRINTS, + OAUTH, + OAUTH_PROXIES, + __api_version__, + get_login_blueprints, +) -__all__ = ("ENDPOINTS", "__api_version__", "OAUTH", "OAUTH_PROXIES", "BLUEPRINTS") +__all__ = ( + "ENDPOINTS", + "__api_version__", + "OAUTH", + "OAUTH_PROXIES", + "BLUEPRINTS", + "get_login_blueprints", +) diff --git a/pydatalab/src/pydatalab/routes/v0_1/__init__.py b/pydatalab/src/pydatalab/routes/v0_1/__init__.py index 93b3a22db..ff32e2707 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/__init__.py +++ b/pydatalab/src/pydatalab/routes/v0_1/__init__.py @@ -2,7 +2,7 @@ from ._version import __api_version__ from .admin import ADMIN -from .auth import AUTH, OAUTH, OAUTH_PROXIES +from .auth import AUTH, OAUTH, OAUTH_PROXIES, get_login_blueprints from .blocks import BLOCKS from .collections import COLLECTIONS from .export import EXPORT @@ -31,4 +31,10 @@ EXPORT, ) -__all__ = ("BLUEPRINTS", "OAUTH", "__api_version__", "OAUTH_PROXIES") +__all__ = ( + "BLUEPRINTS", + "OAUTH", + "__api_version__", + "OAUTH_PROXIES", + "get_login_blueprints", +) diff --git a/pydatalab/src/pydatalab/routes/v0_1/auth.py b/pydatalab/src/pydatalab/routes/v0_1/auth.py index 549d96f99..15d53202d 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/auth.py +++ b/pydatalab/src/pydatalab/routes/v0_1/auth.py @@ -18,7 +18,7 @@ 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 @@ -28,6 +28,7 @@ from pydatalab.models.people import AccountStatus, Identity, IdentityType, Person from pydatalab.mongo import flask_mongo, insert_pydantic_model_fork_safe from pydatalab.send_email import send_mail +from pydatalab.testing_username_password_auth import verify_testing_username_password_credential KEY_LENGTH: int = 32 LINK_EXPIRATION: datetime.timedelta = datetime.timedelta(hours=1) @@ -402,6 +403,8 @@ def wrapped_login_user(*args, **kwargs): EMAIL_BLUEPRINT = Blueprint("email", __name__) +TESTING_USERNAME_PASSWORD_BLUEPRINT = Blueprint("testing_username_password", __name__) + AUTH = Blueprint("auth", __name__) @@ -426,6 +429,15 @@ def wrapped_login_user(*args, **kwargs): } """A dictionary of Flask blueprints corresponding to the supported OAuth providers.""" + +def get_login_blueprints() -> tuple[Blueprint, ...]: + """Return blueprints registered under /login.""" + login_blueprints = tuple(OAUTH.values()) + if CONFIG.TESTING: + login_blueprints += (TESTING_USERNAME_PASSWORD_BLUEPRINT,) + return login_blueprints + + OAUTH_PROXIES: dict[IdentityType, LocalProxy] = { IdentityType.ORCID: orcid, IdentityType.GITHUB: github, @@ -862,6 +874,34 @@ def email_logged_in(): return redirect(referer, 307) +@TESTING_USERNAME_PASSWORD_BLUEPRINT.route("/testing-username-password", methods=["POST"]) +def testing_username_password_login(): + """Testing-only username/password login.""" + if not CONFIG.TESTING: + raise Forbidden("Username/password 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_testing_username_password_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 + + @oauth_authorized.connect_via(OAUTH[IdentityType.GITHUB]) def github_logged_in(blueprint, token): """This Flask signal hooks into any attempt to use the GitHub blueprint, and will diff --git a/pydatalab/src/pydatalab/testing_username_password_auth.py b/pydatalab/src/pydatalab/testing_username_password_auth.py new file mode 100644 index 000000000..8d71b73a6 --- /dev/null +++ b/pydatalab/src/pydatalab/testing_username_password_auth.py @@ -0,0 +1,67 @@ +"""Small file-backed credentials for testing-only username/password 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 + +TESTING_USERNAME_PASSWORD_AUTH_FILENAME = ".testing_username_password_credentials.json" # noqa: S105 + + +def testing_username_password_credentials_path() -> Path: + return Path(CONFIG.FILE_DIRECTORY) / TESTING_USERNAME_PASSWORD_AUTH_FILENAME + + +def load_testing_username_password_credentials() -> dict: + path = testing_username_password_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_testing_username_password_credentials(credentials: dict) -> None: + path = testing_username_password_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_testing_username_password_credential(username: str, user_id: str, password: str) -> None: + username = str(HumanReadableIdentifier(username)) + credentials = load_testing_username_password_credentials() + credentials[username] = { + "user_id": str(user_id), + "password_hash": generate_password_hash(password), + } + save_testing_username_password_credentials(credentials) + + +def verify_testing_username_password_credential(username: str, password: str) -> str | None: + try: + username = str(HumanReadableIdentifier(username)) + except (ValueError, ValidationError): + return None + + credential = load_testing_username_password_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/tasks.py b/pydatalab/tasks.py index 21a364baf..614314bd0 100644 --- a/pydatalab/tasks.py +++ b/pydatalab/tasks.py @@ -165,6 +165,90 @@ def serve(_, host: str = "127.0.0.1", port: int = 5001, reload: bool = True, tes dev.add_task(serve) +@task( + aliases=["create-test-user"], + help={ + "username": "Testing username/password username", + "password": "Testing username/password 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_testing_username_password_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 username/password user.""" + from bson import ObjectId + from pydantic import ValidationError + + from pydatalab.config import CONFIG + from pydatalab.models.people import AccountStatus, Person + from pydatalab.models.utils import HumanReadableIdentifier, UserRole + from pydatalab.mongo import get_database + from pydatalab.testing_username_password_auth import ( + load_testing_username_password_credentials, + set_testing_username_password_credential, + ) + + if not CONFIG.TESTING: + raise SystemExit( + "Testing 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_testing_username_password_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) + user_document = user.dict(by_alias=True, exclude_none=True) + user_document.pop("identities", None) + db.users.insert_one(user_document) + 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_testing_username_password_credential(username, str(user_id), password) + print(f"Testing username/password user {username!r} is linked to {user_id}.") + + +dev.add_task(create_testing_username_password_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 diff --git a/pydatalab/tests/server/test_auth.py b/pydatalab/tests/server/test_auth.py index 092d564af..fae383938 100644 --- a/pydatalab/tests/server/test_auth.py +++ b/pydatalab/tests/server/test_auth.py @@ -1,8 +1,28 @@ from unittest.mock import MagicMock +import pytest +from bson import ObjectId + from pydatalab.routes.v0_1.auth import _check_email_domain +@pytest.fixture() +def testing_username_password_client(app_config, real_mongo_client): + from pydatalab.config import CONFIG + from pydatalab.feature_flags import FEATURE_FLAGS + from pydatalab.main import create_app + + old_testing = CONFIG.TESTING + old_testing_username_password = FEATURE_FLAGS.auth_mechanisms.testing_username_password + app = create_app({**app_config, "TESTING": True}, env_file=False) + try: + with app.test_client() as client: + yield client + finally: + CONFIG.TESTING = old_testing + FEATURE_FLAGS.auth_mechanisms.testing_username_password = old_testing_username_password + + def test_allow_emails(): # Test that a valid email is allowed assert _check_email_domain("test@example.org", ["example.org"]) @@ -62,6 +82,130 @@ def test_magic_links_expected_failures(unauthenticated_client, app): assert len(outbox) == 0 +def test_testing_username_password_login_disabled_outside_testing(unauthenticated_client): + response = unauthenticated_client.post( + "/login/testing-username-password", + json={"username": "test-user", "password": "password"}, + ) + assert response.status_code == 404 + + +def test_testing_username_password_login_success(testing_username_password_client, user_id): + from pydatalab.testing_username_password_auth import ( + set_testing_username_password_credential, + ) + + set_testing_username_password_credential("testing-user", str(user_id), "password") + + response = testing_username_password_client.post( + "/login/testing-username-password", + json={"username": "testing-user", "password": "password"}, + ) + assert response.status_code == 200 + assert response.json["status"] == "success" + + current_user = testing_username_password_client.get("/get-current-user/") + assert current_user.status_code == 200 + assert current_user.json["immutable_id"] == str(user_id) + + +def test_testing_username_password_login_bad_credentials(testing_username_password_client, user_id): + from pydatalab.testing_username_password_auth import ( + set_testing_username_password_credential, + ) + + set_testing_username_password_credential("wrong-password-user", str(user_id), "password") + + response = testing_username_password_client.post( + "/login/testing-username-password", + json={"username": "wrong-password-user", "password": "bad"}, + ) + assert response.status_code == 401 + + response = testing_username_password_client.post( + "/login/testing-username-password", + json={"username": "unknown-user", "password": "password"}, + ) + assert response.status_code == 401 + + +def test_testing_username_password_login_missing_linked_user(testing_username_password_client): + from pydatalab.testing_username_password_auth import ( + set_testing_username_password_credential, + ) + + set_testing_username_password_credential("missing-user", str(ObjectId()), "password") + + response = testing_username_password_client.post( + "/login/testing-username-password", + json={"username": "missing-user", "password": "password"}, + ) + assert response.status_code == 401 + + +def test_testing_username_password_login_deactivated_user( + testing_username_password_client, deactivated_user_id +): + from pydatalab.testing_username_password_auth import ( + set_testing_username_password_credential, + ) + + set_testing_username_password_credential( + "deactivated-testing-user", str(deactivated_user_id), "password" + ) + + response = testing_username_password_client.post( + "/login/testing-username-password", + json={"username": "deactivated-testing-user", "password": "password"}, + ) + assert response.status_code == 401 + + +def test_create_testing_username_password_user_task( + database, testing_username_password_client, monkeypatch +): + import importlib.util + from pathlib import Path + + from pydatalab.config import CONFIG + from pydatalab.testing_username_password_auth import ( + load_testing_username_password_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_testing_username_password_user.body( + None, + username="task-testing-user", + password="password", # noqa: S106 - this feature is for dev testing only + display_name="Task Testing User", + contact_email="task-testing-user@example.org", + role="admin", + account_status="active", + ) + + credentials = load_testing_username_password_credentials() + assert "task-testing-user" in credentials + + user_id = ObjectId(credentials["task-testing-user"]["user_id"]) + user = database.users.find_one({"_id": user_id}) + assert user["display_name"] == "Task Testing User" + assert user["contact_email"] == "task-testing-user@example.org" + assert "identities" not in user + assert database.roles.find_one({"_id": user_id})["role"] == "admin" + + response = testing_username_password_client.post( + "/login/testing-username-password", + json={"username": "task-testing-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..f8b7dabc8 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_username_password"] 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..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,6 +132,12 @@ export default { ); } }, + handleCurrentUserChanged(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..aae0eb0c6 100644 --- a/webapp/src/components/LoginDropdown.vue +++ b/webapp/src/components/LoginDropdown.vue @@ -44,23 +44,114 @@ > Login via email + + diff --git a/webapp/src/server_fetch_utils.js b/webapp/src/server_fetch_utils.js index f52ced172..9b67627e4 100644 --- a/webapp/src/server_fetch_utils.js +++ b/webapp/src/server_fetch_utils.js @@ -643,6 +643,15 @@ export async function requestMagicLink(email_address) { }); } +export async function loginTestingUsernamePassword(username, password) { + await fetch_post(`${API_URL}/login/testing-username-password`, { + username, + password, + }); + invalidateCurrentUserCache(); + return getCurrentUser(); +} + export function searchUsers(query, nresults = 100) { // construct a url with parameters: var url = new URL(`${API_URL}/search-users/`);