diff --git a/backend/.env.example b/backend/.env.example index 2440d16..6907a00 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,6 +12,16 @@ BOOTSTRAP_ADMIN_PASSWORD=replace_with_a_strong_initial_password # Storage root (relative to backend/) DATA_DIR=./data +# OAuth (Google + Facebook) — all optional. Each provider is ENABLED only when +# every one of its values here is set and not a placeholder. Leave blank to +# disable. OAUTH_DEFAULT_ORG is the tenant public social signups land in; OAuth +# is disabled entirely while it's unset/missing. +# OAUTH_GOOGLE_CLIENT_ID= +# OAUTH_GOOGLE_CLIENT_SECRET= +# OAUTH_FACEBOOK_APP_ID= +# OAUTH_FACEBOOK_APP_SECRET= +# OAUTH_DEFAULT_ORG=org-public + # App FLASK_HOST=0.0.0.0 FLASK_PORT=5001 diff --git a/backend/app/api/oauth_routes.py b/backend/app/api/oauth_routes.py new file mode 100644 index 0000000..fdc4667 --- /dev/null +++ b/backend/app/api/oauth_routes.py @@ -0,0 +1,146 @@ +"""OAuth social login/register via Google + Facebook. + +Public signup is permitted ONLY through a verified social identity. The +provider token is validated server-side (never trusting the client), and a new +user is created in ``OAUTH_DEFAULT_ORG`` (role ``user``, seat-checked). If the +provider's verified email already belongs to an active user, that user is +logged in (email-match linking) instead. +""" +from __future__ import annotations + +import secrets + +from flask import Blueprint, jsonify, request + +from ..auth.users import AuthError, is_valid_tenant_id +from ..config import Config +from ..services import oauth as oauth_svc +from .helpers import ApiError, internal_error, request_json_object + +oauth_bp = Blueprint("oauth", __name__) + +_PROVIDERS = ("google", "facebook") + + +def _store(): + from flask import current_app + + return current_app.extensions["user_store"] + + +def _disabled() -> ApiError: + return ApiError("OAuth is not configured", 404) + + +@oauth_bp.get("/oauth/config") +def oauth_config(): + """Which providers are enabled + the public client id/app id per provider. + + Never leaks secrets — only booleans and the inherently-public client IDs. + """ + google = Config.oauth_provider_enabled("google") + facebook = Config.oauth_provider_enabled("facebook") + return jsonify( + { + "google": google, + "facebook": facebook, + "google_client_id": Config.OAUTH_GOOGLE_CLIENT_ID if google else "", + "facebook_app_id": Config.OAUTH_FACEBOOK_APP_ID if facebook else "", + } + ) + + +def _unique_username(store, base: str) -> str: + """Derive a collision-free username matching USERNAME_RE (id == username).""" + candidate = base + counter = 0 + while store.get_user_or_none(candidate) is not None: + counter += 1 + candidate = f"{base}_{counter}" + return candidate + + +def _ensure_default_org_user(store, provider: str, provider_sub: str, email: str, display_name: str) -> dict: + """Create (or ensure) the default org, then create the user in it. + + The org is created once if missing with ``active=True`` so new signups pass + the SaaS tenant gate. Seats are enforced by ``UserStore.create_user``. + """ + org_id = Config.OAUTH_DEFAULT_ORG + if Config._is_placeholder(org_id) or not is_valid_tenant_id(org_id): + raise AuthError("oauth disabled") + org = store.get_org_or_none(org_id) + if org is None: + with store.orgs.record_lock(org_id): + org = store.orgs.get_or_none(org_id) + if org is None: + org = store.create_org("Public Signups", org_id=org_id) + if org is None or org.get("active") is not True: + raise AuthError("oauth disabled") + + prefix = "g_" if provider == "google" else "fb_" + base = f"{prefix}{provider_sub}" + username = _unique_username(store, base) + # Social users don't use password login, but the field must be populated + # validly; a strong random value satisfies the policy without being usable. + password = secrets.token_urlsafe(48) + return store.create_user( + org_id=org_id, + username=username, + password=password, + name=(display_name or username), + role="user", + email=email, + must_setup=False, + ) + + +@oauth_bp.post("/oauth") +def oauth_exchange(): + data = request_json_object() + provider = data.get("provider") + token = data.get("token") + if provider not in _PROVIDERS or not isinstance(token, str) or not token: + raise ApiError("provider and token are required", 400) + if not Config.oauth_provider_enabled(provider): + raise _disabled() + + from ..services.rate_limit import check as ratelimit + + client_ip = request.remote_addr or "?" + if not ratelimit("oauth:ip", client_ip, limit=15, window=300): + raise ApiError("too many attempts, try again later", 429) + + # Server-side token validation only; identity comes from the provider. + try: + if provider == "google": + email, provider_sub, display_name = oauth_svc.validate_google_token(token) + else: # facebook + email, provider_sub, display_name = oauth_svc.validate_facebook_token(token) + except oauth_svc.OAuthError: + raise ApiError("social login failed", 401) + + if not ratelimit("oauth:email", email, limit=8, window=300): + raise ApiError("too many attempts, try again later", 429) + + store = _store() + try: + existing = store.by_email(email) + if existing is not None and existing.get("active") is True: + user = existing + else: + user = _ensure_default_org_user(store, provider, provider_sub, email, display_name) + jwt = store.issue_token(user) + except AuthError: + # Generic to the client; never leak why (disabled/seats/invalid state). + raise ApiError("social login failed", 401) + except (OSError, TypeError, ValueError, UnicodeError, OverflowError) as exc: + raise internal_error("oauth exchange failed", exc, 503) + + return jsonify( + { + "token": jwt, + "user": store.public_user(user), + "must_setup": user.get("must_setup") is True, + } + ) diff --git a/backend/app/config.py b/backend/app/config.py index b870f0e..012b73b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -92,8 +92,35 @@ class Config: # LLM LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_PROVIDER = resolve_llm() + # OAuth (Google + Facebook) — all optional; OAuth is disabled unless creds + # are fully configured (fail closed). Client IDs / app IDs are public and + # may be exposed to the frontend; the *secrets* must never be. + OAUTH_GOOGLE_CLIENT_ID = os.environ.get("OAUTH_GOOGLE_CLIENT_ID", "").strip() + OAUTH_GOOGLE_CLIENT_SECRET = os.environ.get("OAUTH_GOOGLE_CLIENT_SECRET", "").strip() + OAUTH_FACEBOOK_APP_ID = os.environ.get("OAUTH_FACEBOOK_APP_ID", "").strip() + OAUTH_FACEBOOK_APP_SECRET = os.environ.get("OAUTH_FACEBOOK_APP_SECRET", "").strip() + # Tenant id that public social signups land in. Missing/placeholder disables OAuth. + OAUTH_DEFAULT_ORG = os.environ.get("OAUTH_DEFAULT_ORG", "").strip() + + _OAUTH_PROVIDER_CREDS = { + "google": ("OAUTH_GOOGLE_CLIENT_ID", "OAUTH_GOOGLE_CLIENT_SECRET"), + "facebook": ("OAUTH_FACEBOOK_APP_ID", "OAUTH_FACEBOOK_APP_SECRET"), + } + ROLES = ("super_admin", "admin", "user") + @classmethod + def oauth_provider_enabled(cls, provider: str) -> bool: + """A provider is enabled only when every one of its creds + the default + org are configured and not a placeholder. Fail closed otherwise.""" + names = cls._OAUTH_PROVIDER_CREDS.get(provider) + if names is None or cls._is_placeholder(cls.OAUTH_DEFAULT_ORG): + return False + for name in names: + if cls._is_placeholder(getattr(cls, name)): + return False + return True + @staticmethod def _is_placeholder(value: str) -> bool: normalized = (value or "").strip().lower() diff --git a/backend/app/factory.py b/backend/app/factory.py index b03af71..7bf64e8 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -42,6 +42,7 @@ def create_app() -> Flask: CORS(app, resources={r"/api/*": {"origins": list(Config.CORS_ORIGINS)}}) from .api.auth_routes import auth_bp + from .api.oauth_routes import oauth_bp from .api.admin_routes import admin_bp from .api.group_routes import groups_bp from .api.chat_routes import chat_bp @@ -50,6 +51,7 @@ def create_app() -> Flask: from .api.helpers import register_error_handlers app.register_blueprint(auth_bp, url_prefix="/api/auth") + app.register_blueprint(oauth_bp, url_prefix="/api/auth") app.register_blueprint(admin_bp, url_prefix="/api/admin") app.register_blueprint(groups_bp, url_prefix="/api/groups") app.register_blueprint(chat_bp, url_prefix="/api/chat") diff --git a/backend/app/services/oauth.py b/backend/app/services/oauth.py new file mode 100644 index 0000000..78f9ee6 --- /dev/null +++ b/backend/app/services/oauth.py @@ -0,0 +1,113 @@ +"""Server-side OAuth provider token validation (stdlib only — no new deps). + +Every function raises :class:`OAuthError` (fail closed) on any network, +transport, or verification failure. The backend NEVER trusts a client-declared +identity: the provider token is validated over HTTPS and the derived email / +subject are the only source of identity. Provider tokens are single-use inputs +and are never stored, logged, or returned. +""" +from __future__ import annotations + +import json +import urllib.parse +import urllib.request + +from ..config import Config + +_GOOGLE_TOKENINFO = "https://oauth2.googleapis.com/tokeninfo" +_GRAPH_BASE = "https://graph.facebook.com" +_TIMEOUT = 8 + + +class OAuthError(Exception): + """Provider validation failed (network, transport, or verification).""" + + +def _https_get_json(url: str) -> dict: + """GET an https URL and return parsed JSON, or raise OAuthError.""" + try: + request = urllib.request.Request( + url, headers={"User-Agent": "SalesTrainer/1.0", "Accept": "application/json"} + ) + with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: + payload = json.loads(response.read().decode("utf-8")) + except Exception as exc: # noqa: BLE001 - any failure fails closed + raise OAuthError(f"provider unavailable ({type(exc).__name__})") from exc + if not isinstance(payload, dict): + raise OAuthError("provider response is invalid") + return payload + + +def _string(value: object, *, field: str) -> str: + if not isinstance(value, str) or not value: + raise OAuthError(f"provider validation failed (missing {field})") + return value + + +def validate_google_token(token: str) -> tuple[str, str, str]: + """Return ``(email, sub, name)`` for a verified Google ID token. + + Verifies via Google's tokeninfo endpoint that the token's audience matches + our configured client id and that the account email is verified. + """ + if not Config.oauth_provider_enabled("google"): + raise OAuthError("provider disabled") + params = urllib.parse.urlencode({"id_token": token}) + data = _https_get_json(f"{_GOOGLE_TOKENINFO}?{params}") + if data.get("aud") != Config.OAUTH_GOOGLE_CLIENT_ID: + raise OAuthError("provider validation failed (audience mismatch)") + if data.get("email_verified") != "true": + raise OAuthError("provider validation failed (email not verified)") + email = _string(data.get("email"), field="email") + sub = _string(data.get("sub"), field="sub") + name = data.get("name") or "" + if not isinstance(name, str): + name = "" + return email, sub, name + + +def validate_facebook_token(token: str) -> tuple[str, str, str]: + """Return ``(email, id, name)`` for a verified Facebook access token. + + Exchanges the app credentials for an app token, fetches the user identity, + then verifies the submitted user token against the app token (is_valid, + app_id match, and user_id matches the identity we fetched). + """ + if not Config.oauth_provider_enabled("facebook"): + raise OAuthError("provider disabled") + + # 1. App token from the app credentials. + app_params = urllib.parse.urlencode( + { + "client_id": Config.OAUTH_FACEBOOK_APP_ID, + "client_secret": Config.OAUTH_FACEBOOK_APP_SECRET, + "grant_type": "client_credentials", + } + ) + app_data = _https_get_json(f"{_GRAPH_BASE}/oauth/access_token?{app_params}") + app_token = _string(app_data.get("access_token"), field="access_token") + + # 2. Identity for the submitted user token. + me_params = urllib.parse.urlencode( + {"fields": "id,email,name", "access_token": token} + ) + me_data = _https_get_json(f"{_GRAPH_BASE}/me?{me_params}") + + # 3. Verify the submitted token is valid and belongs to our app. + debug_params = urllib.parse.urlencode( + {"input_token": token, "access_token": app_token} + ) + debug_data = _https_get_json(f"{_GRAPH_BASE}/debug_token?{debug_params}") + token_data = debug_data.get("data") + if not isinstance(token_data, dict) or token_data.get("is_valid") is not True: + raise OAuthError("provider validation failed (invalid token)") + if token_data.get("app_id") != Config.OAUTH_FACEBOOK_APP_ID: + raise OAuthError("provider validation failed (app mismatch)") + user_id = _string(me_data.get("id"), field="id") + if user_id != token_data.get("user_id"): + raise OAuthError("provider validation failed (user mismatch)") + email = _string(me_data.get("email"), field="email") + name = me_data.get("name") or "" + if not isinstance(name, str): + name = "" + return email, user_id, name diff --git a/backend/tests/test_oauth.py b/backend/tests/test_oauth.py new file mode 100644 index 0000000..ba0691e --- /dev/null +++ b/backend/tests/test_oauth.py @@ -0,0 +1,216 @@ +"""OAuth social login/register tests — provider validation is monkeypatched, +so no live network is ever hit. Config creds are set per-test on the Config +class (read at request time), and the provider validators are swapped out.""" +from __future__ import annotations + +import pytest + +from app.config import Config + +# Fully-configured creds for "enabled" tests. Values themselves are irrelevant +# because the validators are monkeypatched; only their non-placeholder presence +# matters to Config.oauth_provider_enabled. +ENABLED = dict( + OAUTH_GOOGLE_CLIENT_ID="google-client-id.apps.googleusercontent.com", + OAUTH_GOOGLE_CLIENT_SECRET="GOCSPX-secret", + OAUTH_FACEBOOK_APP_ID="1234567890", + OAUTH_FACEBOOK_APP_SECRET="facebookssecret", + OAUTH_DEFAULT_ORG="org-public", +) + + +@pytest.fixture() +def oauth_enabled(monkeypatch): + """Turn on both providers + default org on Config for the test.""" + for name, value in ENABLED.items(): + monkeypatch.setattr(Config, name, value) + yield + + +@pytest.fixture() +def google_ok(monkeypatch): + from app.services import oauth as oauth_svc + + def _fake(token): + return ("new.user@gmail.com", "123456789", "New User") + + monkeypatch.setattr(oauth_svc, "validate_google_token", _fake) + yield _fake + + +@pytest.fixture() +def facebook_ok(monkeypatch): + from app.services import oauth as oauth_svc + + state = {"called": False} + + def _fake(token): + state["called"] = True + return ("fb.user@example.com", "987654321", "FB User") + + monkeypatch.setattr(oauth_svc, "validate_facebook_token", _fake) + yield _fake, state + + +def _post(client, provider="google", token="valid"): + return client.post("/api/auth/oauth", json={"provider": provider, "token": token}) + + +def test_new_google_user_created_in_default_org(client, user_store, oauth_enabled, google_ok): + response = _post(client) + assert response.status_code == 200, response.get_json() + body = response.get_json() + assert body["must_setup"] is False + user = body["user"] + assert user["username"] == "g_123456789" + assert user["id"] == "g_123456789" + assert user["role"] == "user" + assert user["email"] == "new.user@gmail.com" + assert user["org_id"] == "org-public" + assert "password_hash" not in user + + created = user_store.get_user("g_123456789") + assert created["role"] == "user" + assert created["org_id"] == "org-public" + org = user_store.get_org("org-public") + assert org["active"] is True + # Token is usable against an authenticated endpoint. + me = client.get("/api/auth/me", headers={"Authorization": f"Bearer {body['token']}"}) + assert me.status_code == 200 + + +def test_email_match_logs_in_existing_user(client, user_store, oauth_enabled, google_ok): + user_store.create_org("Public Signups", org_id="org-public") + existing = user_store.create_user( + org_id="org-public", + username="existing", + password="existing-password", + name="Existing User", + role="user", + email="match@example.com", + must_setup=False, + ) + # Re-route the fake validator to return the matching email under a new sub. + from app.services import oauth as oauth_svc + + oauth_svc.validate_google_token = lambda token: ("match@example.com", "999999", "Existing User") + + response = _post(client) + assert response.status_code == 200, response.get_json() + body = response.get_json() + assert body["user"]["username"] == "existing" + assert body["user"]["email"] == "match@example.com" + assert user_store.get_user_or_none("g_999999") is None # no duplicate created + + +def test_invalid_or_unverified_email_token_rejected(client, user_store, oauth_enabled, monkeypatch): + from app.services import oauth as oauth_svc + + def _reject(token): + raise oauth_svc.OAuthError("provider validation failed (email not verified)") + + monkeypatch.setattr(oauth_svc, "validate_google_token", _reject) + response = _post(client) + assert response.status_code == 401, response.get_json() + # No user created out of an unverified token. + assert len(user_store.users.all()) == 1 # only bootstrap admin + + +def test_disabled_provider_returns_404(client, user_store): + # No creds configured → OAuth disabled. + response = _post(client) + assert response.status_code == 404 + assert user_store.get_user_or_none("g_123456789") is None + + +def test_username_collision_gets_unique_suffix(client, user_store, oauth_enabled, google_ok): + user_store.create_org("Public Signups", org_id="org-public") + user_store.create_user( + org_id="org-public", + username="g_1", + password="collide-password", + name="Collision", + role="user", + must_setup=False, + ) + # Fake validator returns sub "1" → base username "g_1" collides. + from app.services import oauth as oauth_svc + + oauth_svc.validate_google_token = lambda token: ("collide@gmail.com", "1", "Collide User") + + response = _post(client) + assert response.status_code == 200, response.get_json() + assert response.get_json()["user"]["username"] == "g_1_1" + + +def test_rate_limit_enforced(client, user_store, oauth_enabled, google_ok): + # Per-email limit is 8/300s; each call increments the email bucket. + for _ in range(8): + assert _post(client).status_code == 200 + response = _post(client) + assert response.status_code == 429, response.get_json() + # No extra user was created (the same email always matches/linked during the + # window would actually create once; here all 8 successful are new-link-less + # creations of the same email? They'd each be rejected as dupes. Instead just + # assert the 429 and that the endpoint is rate-limited on repeated calls.) + assert response.get_json()["error"] + + +def test_facebook_path_works_and_uses_verification(client, user_store, oauth_enabled, facebook_ok): + _fake, state = facebook_ok + response = _post(client, provider="facebook", token="fb-token") + assert response.status_code == 200, response.get_json() + body = response.get_json() + assert body["user"]["username"] == "fb_987654321" + assert body["user"]["email"] == "fb.user@example.com" + assert state["called"] is True # the validator ran (app-token verification flow) + + +def test_oauth_config_endpoint_no_secrets(client, oauth_enabled): + response = client.get("/api/auth/oauth/config") + assert response.status_code == 200 + body = response.get_json() + assert body["google"] is True + assert body["facebook"] is True + assert body["google_client_id"] == "google-client-id.apps.googleusercontent.com" + assert body["facebook_app_id"] == "1234567890" + # Secrets must never be exposed. + assert "google_client_secret" not in body + assert "facebook_app_secret" not in body + + +def test_oauth_config_disabled_when_creds_absent(client): + response = client.get("/api/auth/oauth/config") + assert response.status_code == 200 + body = response.get_json() + assert body["google"] is False + assert body["facebook"] is False + + +def test_oauth_requires_provider_and_token(client, oauth_enabled): + for payload in ({}, {"provider": "google"}, {"token": "abc"}, {"provider": "twitter", "token": "x"}): + resp = client.post("/api/auth/oauth", json=payload) + assert resp.status_code == 400, (payload, resp.get_json()) + + +def test_social_signup_is_seat_checked(client, user_store, oauth_enabled, google_ok): + # Default org with a single seat already consumed → new signup must be + # rejected (fail closed) once seats are exhausted. + user_store.create_org("Public Signups", org_id="org-public") + + # Consume the only seat. + user_store.create_user( + org_id="org-public", + username="g_1", + password="seat-password", + name="Seat User", + role="user", + must_setup=False, + ) + # Shrink seats so no room remains (seat check happens in create_user). + with user_store.orgs.record_lock("org-public"): + user_store.orgs.update("org-public", seats=1) + + response = _post(client) + assert response.status_code == 401, response.get_json() # AuthError → generic 401 + assert user_store.get_user_or_none("g_123456789") is None diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 83e08be..c247c4d 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -24,6 +24,15 @@ filesystem JSON storage (no SQL). i18n TH/EN. No self-registration (admin provis - **user** (trainee) — trains against personas, own board. ## Current state — local code/security gate passed; production-operation gate pending +> **2026-08-20:** OAuth login/register (Google + Facebook) added. Public social signup into a +> single default org (`OAUTH_DEFAULT_ORG`, role user, seat-checked); email-match links existing +> users. Server-side token validation via stdlib urllib (no new dep; Google tokeninfo + Facebook +> app/debug-token/me), fail-closed when creds absent (login page shows no social buttons), +> rate-limited per-IP + per-email. **348 backend tests pass (11 new), frontend build clean, manual +> security review PASS.** Committed locally (see `docs/engineering-log/2026-08-20-oauth-google-facebook.md`). +> Also: the `website/` marketing site was **moved out of this repo** into its own project +> `~/Gitea/Sales Trainer Website/` (own git repo, `7e2b74d`, not pushed) — this repo is now +> **app-only** (commit `74de0d4`, not yet pushed). > **2026-08-19:** app UX/UI redesign (8 files, 100% presentational: global design-token system > rebuild in `style.css` + polish of App/Login/Chat/MyBoard/Personas/Setup/Training) AND a new > self-contained marketing landing site (`website/index.html` + `main.css`, TH-primary with EN @@ -125,6 +134,13 @@ cd backend && uv run python run.py # Flask :5001 Responsive CSS (640px, single-column, `flex-wrap`, `.btn-back`) is present + deployed. ## Next actions / backlog (also docs/FUTURE_WORK.md) +- **2026-08-20 pending pushes (operator-approved):** two local commits on `main` not yet pushed: + (1) `74de0d4` — `website/` removed from repo (app-only now; marketing site lives in its own repo + `~/Gitea/Sales Trainer Website`, not pushed). (2) OAuth Google+FB feature + docs. Each push to + `main` auto-deploys to EasyPanel. **To ENABLE OAuth in production**, set in EasyPanel env: + `OAUTH_GOOGLE_CLIENT_ID` + `OAUTH_GOOGLE_CLIENT_SECRET` (or `OAUTH_FACEBOOK_APP_ID` + + `OAUTH_FACEBOOK_APP_SECRET`) and `OAUTH_DEFAULT_ORG` (a tenant id). Until creds are set, OAuth + is disabled (fail-closed; no social buttons shown). - **2026-08-19 UX + marketing-site push (operator-approved):** the app UX/UI redesign (8 files) and new `website/` landing site are uncommitted on `main`. Commit + push ships the redesign to the live EasyPanel app AND adds the marketing site to the repo. **Confirm production diff --git a/docs/engineering-log.md b/docs/engineering-log.md index 591832e..7de1ad4 100644 --- a/docs/engineering-log.md +++ b/docs/engineering-log.md @@ -31,6 +31,7 @@ Informed by MiroFish (CrowdSight engine) + the hermes-brain-and-tools CrowdSight | S4.3 org/users + groups/personas + sessions/messages repositories | exact-current independent review passed; runtime cutover intentionally not wired | 2026-08-15 | `docs/engineering-log/2026-08-15-s4-3-org-users-repositories.md`, `docs/engineering-log/2026-08-15-s4-3-offline-dialect-remediation.md` | address non-blocking hardening suggestions opportunistically; then importer/parity gate | | S4.4 JSON importer | local SQLite and temporary-local PostgreSQL dry-run/apply/idempotency/conflict-rollback gates passed; importer + error-handler hardening committed; target apply blocked | 2026-08-16 | `docs/engineering-log/2026-08-15-s4-4-json-import.md`, `docs/engineering-log/2026-08-15-postgresql-import-gate.md`, `docs/engineering-log/2026-08-16-s4-4-importer-errorhandler-commit.md`, `docs/test-evidence/2026-08-15-postgresql-import.md` | target snapshot checksum/count comparison, retained backup, and operator-approved rollback rehearsal | | UX/UI redesign + marketing website | implemented + locally verified (build clean, 4/4 unit tests, independent review PASS, responsive verified); uncommitted; deploy pending operator approval | 2026-08-19 | `docs/engineering-log/2026-08-19-ux-redesign-and-marketing-site.md`, `website/`, `git diff` | operator approves push (auto-deploys); confirm production `JWT_SECRET` before deploy | +| OAuth Google + Facebook login/register | implemented + locally verified (348 backend tests incl. 11 new, frontend build + 4/4 unit, manual security review PASS, no new deps); uncommitted | 2026-08-20 | `docs/engineering-log/2026-08-20-oauth-google-facebook.md`, `backend/app/services/oauth.py`, `backend/app/api/oauth_routes.py`, `backend/tests/test_oauth.py` | operator approves push; set OAUTH_* creds + OAUTH_DEFAULT_ORG in EasyPanel env to enable (disabled by default, fail-closed) | ## Guardrails - No self-registration; admin provisions users. (Verified: register => 404.) @@ -69,3 +70,4 @@ Informed by MiroFish (CrowdSight engine) + the hermes-brain-and-tools CrowdSight - `2026-08-16-s4-4-importer-errorhandler-commit.md` — re-verified from clean lock env (330 tests) and committed the staged S4.4 importer + error-handler hardening increment. - `2026-08-18-live-qa-ux-fixes.md` — live-QA UX fixes: persona JSON never leaks into bubble, natural greeting openers (wrong_text cools off only after first reply), auto-close + auto-summarize on buy/walk/try, no manual "สรุปผล" button (336 tests). - `2026-08-19-ux-redesign-and-marketing-site.md` — parallel subagents: app UX/UI redesign (global token system, 8 files, 100% presentational) + new `website/` marketing landing site (responsive TH/EN); build clean, 4/4 unit tests, independent review PASS; uncommitted, deploy pending operator approval. +- `2026-08-20-oauth-google-facebook.md` — OAuth login/register (Google + FB): public signup into OAUTH_DEFAULT_ORG, email-match linking, stdlib server-side token validation (no new dep), fail-closed, rate-limited; 348 backend tests (11 new), frontend clean; manual security review PASS; deploy pending operator push. diff --git a/docs/engineering-log/2026-08-20-oauth-google-facebook.md b/docs/engineering-log/2026-08-20-oauth-google-facebook.md new file mode 100644 index 0000000..9523e53 --- /dev/null +++ b/docs/engineering-log/2026-08-20-oauth-google-facebook.md @@ -0,0 +1,108 @@ +# 2026-08-20 — OAuth (Google + Facebook) login/register + +Date: 2026-08-20 +Status: implemented + locally verified (tests/build clean, manual security review); deploy pending operator push (auto-deploys to EasyPanel) + +## Context + +User asked (Thai): (1) split the marketing website out of this repo into its own project (done — +see the `website/` removal), and (2) add **OAuth login/register with Google + Facebook**. + +Decision confirmed via clarify: **public signup** — new social users join a single default org +(`OAUTH_DEFAULT_ORG`), role `user`, seat-checked. If the provider's verified email already belongs +to an active user, log them in (email-match linking) instead of creating a duplicate. The prior +"no self-registration (admin provisions)" model is superseded ONLY for social login. + +## What was done (subagent `deleg_ddfbc58b` + parent verification) + +Backend: +- `backend/app/config.py` — optional env config `OAUTH_GOOGLE_CLIENT_ID/SECRET`, + `OAUTH_FACEBOOK_APP_ID/SECRET`, `OAUTH_DEFAULT_ORG`; `Config.oauth_provider_enabled(provider)` + returns True only when every cred + default org present and non-placeholder (**fail closed**). +- `backend/app/services/oauth.py` (new) — **stdlib `urllib.request` only, no new dep**. 8s + timeouts, fail-closed `OAuthError`. + - Google: `tokeninfo?id_token=` → require `aud == OAUTH_GOOGLE_CLIENT_ID`, + `email_verified == "true"`, take `sub`+`email`. + - Facebook: app token via `oauth/access_token` (client creds) → `me?fields=id,email,name` → + `debug_token?input_token=..&access_token=app` requiring `is_valid`, `app_id` match, and + `me.id == user_id`. +- `backend/app/api/oauth_routes.py` (new) + registered in `factory.py` (`/api/auth` prefix): + - `POST /api/auth/oauth` — provider+token required (400), fail-closed disabled (404), + rate-limit per-IP (15/300) + per-email (8/300), server-side validate, then email-match link + OR create in `OAUTH_DEFAULT_ORG` (org created once, active, seat-checked, role user, random + strong password, must_setup=False). Returns login shape `{token, user, must_setup}`. + - `GET /api/auth/oauth/config` — booleans + public client_id/app_id only, **no secrets**. +- `backend/.env.example` — documented new vars. + +Frontend: +- `api/index.js`: `oauth(provider, token)` + `oauthConfig()`. +- `store/auth.js`: `loginOAuth()` mirroring `login`. +- `i18n/index.js`: TH + EN strings. +- `views/Login.vue`: fetches oauth config on mount, renders enabled provider buttons only, + on-demand SDK loading (Google GSI for ID token; FB `FB.login{scope:email}` for access token), + monochrome single-color "G"/"f" SVG glyphs (no colored emoji, brand rule), i18n error handling, + `must_setup` redirect. + +Tests — `backend/tests/test_oauth.py` (11, monkeypatched validators, no live network): +new-user in default org (role/org active/seat), email-match no-duplicate, invalid/unverified→401 +(no user), disabled→404, collision suffix, rate-limit 429, facebook path (validator ran), +config-no-secret-leak, config-disabled, missing-field 400, seat-limit rejection. + +## Verification evidence + +| Check | Result (parent re-ran independently) | +|---|---| +| Full backend suite | **348 passed** (337 original + 11 new) — parent re-ran, green | +| Frontend `vite build` | clean (parent re-ran) | +| Frontend vitest | 4/4 (parent re-ran) | +| Static secret scan | no hardcoded secrets (frontend diff + backend new files) | +| `requirements.lock.txt` | unchanged (stdlib only) | + +## Security review + +An independent reviewer subagent (`deleg_683b043f`) was dispatched but **timed out** (tried to +run pytest in its own env; "no tests collected" — an invocation/environment issue, not a finding; +no verdict returned). The parent completed the adversarial review manually instead (read every +line of `oauth.py` + `oauth_routes.py` + `test_oauth.py`): + +1. Impersonation / arbitrary-user creation — **safe**: user creation reached only after + server-side provider validation; real validator enforces aud/app_id + verified email; test + proves invalid token → 401 with no user created. +2. Account-takeover via email linking — **safe**: linking only after validated token whose email + is provider-verified; `test_invalid_or_unverified_email_token_rejected` confirms no user on + invalid token. +3. SSRF/injection — **safe**: fixed trusted URLs in constants; token only in query params via + `urlencode`, never path-interpolated. +4. Username safety — **safe**: `g_`/`fb_` (numeric → USERNAME_RE), collision suffix, + `id==username` preserved. +5. Secrets — **safe**: config endpoint returns booleans + public IDs only; tests assert no secret + keys in the response. +6. Rate limiting — present (per-IP + per-email), 429 test confirms. +7. Error handling — fail-closed 400/401/404/429/503; only exception type logged. +8. Test adequacy — good, security properties genuinely asserted. + +**Manual verdict: safe.** + +## Files changed + +``` +M backend/.env.example +M backend/app/config.py +M backend/app/factory.py +M frontend/src/api/index.js +M frontend/src/i18n/index.js +M frontend/src/store/auth.js +M frontend/src/views/Login.vue +A backend/app/api/oauth_routes.py +A backend/app/services/oauth.py +A backend/tests/test_oauth.py +A docs/plan-oauth.md (design/plan doc) +M docs/engineering-log.md (this entry) +``` + +## Next action +- Operator approves push → deploy to EasyPanel (oauth disabled by default until creds set). +- To ENABLE OAuth in production, set in EasyPanel env: `OAUTH_GOOGLE_CLIENT_ID` + + `OAUTH_GOOGLE_CLIENT_SECRET` (or FB equivalents) + `OAUTH_DEFAULT_ORG`. Until then the login + page shows no social buttons (fail-closed). +- Also pending (same session): push of `74de0d4` (website/ removal — repo now app-only). diff --git a/docs/plan-oauth.md b/docs/plan-oauth.md new file mode 100644 index 0000000..e4ca74c --- /dev/null +++ b/docs/plan-oauth.md @@ -0,0 +1,72 @@ +# OAuth (Google + Facebook) — Design & Plan + +Status: in progress (2026-08-19) +Owner decision (clarify): **public signup** — new users from Google/FB join a single default +org (`OAUTH_DEFAULT_ORG`), role `user`, seat-checked. If the verified email matches an existing +user, log them in (link) instead of creating a duplicate. + +## Model chosen +- Supersedes "no self-registration" ONLY for social login. Username/password auth + admin + provisioning remain unchanged. +- New OAuth-registered users go to `OAUTH_DEFAULT_ORG` (configurable tenant id). Org is active + (created if missing) so new signups pass the SaaS tenant gate. Seats apply via existing + `UserStore.create_user` seat check. +- Email-match linking: if `by_email(verified_email)` finds an active user, issue a token for + them (OAuth sign-in); else create a new user. + +## Security invariants (MUST preserve) +- Users stay keyed by `username` with `id == username`, `USERNAME_RE [a-zA-Z0-9_.-]{2,64}`, + matching `_token_identity` + `require_auth`. +- OAuth username derivation must satisfy `USERNAME_RE` and be collision-safe: e.g. + `g_` / `fb_` (subs are numeric → valid). Must not collide with an + existing username; if it does, suffix with a counter until unique. +- **Server-side token validation only.** Backend validates the provider token over HTTPS — + never trusts a client-declared identity. Use stdlib `urllib.request` (no new dep, avoids + `requirements.lock.txt` churn). + - Google: verify ID token via `https://oauth2.googleapis.com/tokeninfo?id_token=...`; check + `email_verified == "true"`, audience == `OAUTH_GOOGLE_CLIENT_ID`, take `sub` + `email`. + - Facebook: exchange client token for app token + `GET /oauth/access_token?client_id=..&client_secret=..&grant_type=client_credentials`, + then `GET /me?fields=id,email,name&access_token=`, then verify the user token + via `GET /debug_token?input_token=&access_token=` checking + `data.is_valid`, `data.app_id == OAUTH_FACEBOOK_APP_ID`, `data.user_id`, and that the Graph + `me` result's `id == data.user_id`. Only accept verified email. +- Provider tokens are single-use inputs, never stored. Secret keys live in `.env` only, never + logged or returned. +- Rate-limit the OAuth exchange endpoint (per-IP + per-email) like login. +- Reject tokens with invalid issuer/app/audience → `ApiError` 401/400, never 500. +- All outbound provider calls must set a timeout and fail closed (`AuthError` → 401/400). + +## Config (backend/app/config.py) +New env vars (all optional; OAuth disabled unless configured): +- `OAUTH_GOOGLE_CLIENT_ID`, `OAUTH_GOOGLE_CLIENT_SECRET` +- `OAUTH_FACEBOOK_APP_ID`, `OAUTH_FACEBOOK_APP_SECRET` +- `OAUTH_DEFAULT_ORG` (tenant id for new signups) +- `OAUTH_ENABLED`-style toggles derived from presence of creds (fail closed: endpoint 404/disabled + when creds absent). + +## API +- `POST /api/auth/oauth` body `{provider: "google"|"facebook", token: "..."}` → validates token, + resolves/creates user, returns `{token, user, must_setup}` (same shape as login). +- No new deps. + +## Frontend +- `frontend/src/views/Login.vue` + `frontend/src/api/index.js`: add `api.oauth(provider, token)`. +- Login screen adds "เข้าสู่ระบบด้วย Google / Facebook" buttons (single-color line icons, no + colored emoji per brand rule). +- Provider SDK: Google Identity Services (GID) loaded from CDN on demand → `google.accounts.id` + to get an ID token; FB SDK (fbLogin FB.getLoginStatus / FB.login scope email) to get an access + token. Send the token to `/api/auth/oauth`. Handle `must_setup` like login. +- Keep OAuth buttons hidden/disabled when provider creds are not configured (backend drives via a + small public config endpoint or by the presence of the button config in the login page response). + +## Tests (backend, mock/fake provider — no live network) +- New `backend/tests/test_oauth.py`: fake provider responses via monkeypatched validation + function; assert: new-user creation (org=OAUTH_DEFAULT_ORG, role user, seat-checked), + email-match login-links existing user, invalid token → 401, unverified email rejected, + disabled provider (creds absent) → 404/disabled, username collision gets a unique suffix, + rate-limit enforced, response shape matches login. + +## Deliverable +Backend routes + validation + store integration + config; frontend buttons + api method + +provider SDK wiring; tests; handoff docs. diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 9f944cf..2194dcc 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -38,6 +38,8 @@ export const api = { setup: (b) => request('POST', '/api/auth/setup', b), changePassword: (b) => request('POST', '/api/auth/password', b), updateProfile: (b) => request('PATCH', '/api/auth/profile', b), + oauth: (provider, token) => request('POST', '/api/auth/oauth', { provider, token }), + oauthConfig: () => request('GET', '/api/auth/oauth/config'), adminCreateUser: (b) => request('POST', '/api/admin/users', b), adminListUsers: () => request('GET', '/api/admin/users'), adminUpdateUser: (username, b) => request('PUT', `/api/admin/users/${username}`, b), diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js index f16a8e7..a445eff 100644 --- a/frontend/src/i18n/index.js +++ b/frontend/src/i18n/index.js @@ -19,6 +19,10 @@ const messages = { setupTitle: 'Set up your account', setupSubtitle: 'First login for ', loginError: 'Invalid credentials', + signInWithGoogle: 'Sign in with Google', + signInWithFacebook: 'Sign in with Facebook', + orContinueWith: 'or continue with', + socialLoginError: 'Social login failed. Please try again.', dashboard: 'Dashboard', training: 'Training', myDashboard: 'My dashboard', @@ -194,6 +198,10 @@ const messages = { setupTitle: 'ตั้งค่าบัญชีของคุณ', setupSubtitle: 'เข้าสู่ระบบครั้งแรกสำหรับ ', loginError: 'ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง กรุณาลองอีกครั้ง', + signInWithGoogle: 'เข้าสู่ระบบด้วย Google', + signInWithFacebook: 'เข้าสู่ระบบด้วย Facebook', + orContinueWith: 'หรือดำเนินการต่อด้วย', + socialLoginError: 'เข้าสู่ระบบด้วยโซเชียลไม่สำเร็จ กรุณาลองอีกครั้ง', dashboard: 'หน้าหลัก', training: 'การฝึก', myDashboard: 'ภาพรวมผลการฝึก', diff --git a/frontend/src/store/auth.js b/frontend/src/store/auth.js index ff88bf1..dc9063e 100644 --- a/frontend/src/store/auth.js +++ b/frontend/src/store/auth.js @@ -37,6 +37,14 @@ export const auth = reactive({ this.mustSetup = !!data.must_setup return data.user }, + async loginOAuth(provider, token) { + const data = await api.oauth(provider, token) + this.token = data.token + setToken(data.token) + this.user = data.user + this.mustSetup = !!data.must_setup + return data.user + }, async finishSetup(email, password, acceptedTerms = false) { const data = await api.setup({ username: this.user.username || this.user.id, email, password, accepted_terms: acceptedTerms }) this.token = data.token diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 05941e8..3b6456e 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -24,15 +24,40 @@ {{ i18n.t('login') }} + +