Files
sales-trainer/backend/app/api/oauth_routes.py
Macky d6e7cffc84 feat(auth): Google + Facebook OAuth login/register
Public social signup into OAUTH_DEFAULT_ORG (role user, seat-checked);
email-match links existing active user instead of duplicating. Server-side
provider token validation via stdlib urllib only (no new dep): Google
tokeninfo (aud + email_verified) and Facebook app/debug-token/me (is_valid,
app_id, me.id==user_id). Fail-closed when creds unconfigured, rate-limited
per-IP + per-email, /oauth/config leaks no secrets. Frontend: login buttons
(only enabled providers), GSI + FB SDK on-demand, monochrome glyphs, TH/EN.

Login page shows social buttons only when backend reports provider enabled.

348 backend tests pass (337 + 11 new OAuth), frontend build + 4/4 unit
clean, manual security review PASS. Not pushed (push auto-deploys).
2026-08-20 10:57:04 +07:00

147 lines
5.1 KiB
Python

"""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,
}
)