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).
147 lines
5.1 KiB
Python
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,
|
|
}
|
|
)
|