diff --git a/.env.example b/.env.example index a79bb6ab..db67c97d 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,8 @@ ENV=production POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/ # Unified Auth (Supabase + Google/Email) POLYWEATHER_AUTH_ENABLED=false +# If true: website APIs require login; if false: guest access, login optional. +POLYWEATHER_AUTH_REQUIRED=false # If true, authenticated users must also have an active row in `subscriptions`. POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false SUPABASE_URL= diff --git a/docs/SUPABASE_SETUP_ZH.md b/docs/SUPABASE_SETUP_ZH.md index b146bcb1..6c8b4ff0 100644 --- a/docs/SUPABASE_SETUP_ZH.md +++ b/docs/SUPABASE_SETUP_ZH.md @@ -37,6 +37,8 @@ NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= POLYWEATHER_AUTH_ENABLED=true +# true: 强制登录;false: 游客可用(可选登录) +POLYWEATHER_AUTH_REQUIRED=false POLYWEATHER_API_BASE_URL=http://:8000 POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= ``` @@ -45,6 +47,8 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= ```env POLYWEATHER_AUTH_ENABLED=true +# true: 后端 API 强制鉴权;false: 游客可访问,若带会话则自动识别用户 +POLYWEATHER_AUTH_REQUIRED=false POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false SUPABASE_URL= SUPABASE_ANON_KEY= @@ -63,13 +67,14 @@ POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=true ## 5. entitlement 策略 -- `POLYWEATHER_AUTH_ENABLED=true`:要求请求携带有效 Supabase 用户会话。 -- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=true`:额外要求 `subscriptions` 表里存在有效 `active` 记录。 +- `POLYWEATHER_AUTH_ENABLED=true`:启用 Supabase 登录能力(Google/邮箱)。 +- `POLYWEATHER_AUTH_REQUIRED=true`:网站与后端 API 强制登录。 +- `POLYWEATHER_AUTH_REQUIRED=false`:游客可访问全部功能,用户可主动登录。 +- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=true`:在强制鉴权模式下,额外要求 `subscriptions` 表里存在有效 `active` 记录。 ## 6. 验证 1. 访问 `/auth/login`,测试 Google 一键登录。 -2. 登录后访问首页,确认页面可用。 -3. 调用 `/api/cities`,确认返回 200。 -4. 退出登录后再次访问,确认被重定向到登录页。 - +2. `POLYWEATHER_AUTH_REQUIRED=false` 时,未登录访问首页与 `/api/cities` 应返回 200。 +3. 登录后访问 `/api/auth/me`,应返回 `authenticated=true` 与 `user_id`。 +4. `POLYWEATHER_AUTH_REQUIRED=true` 时,未登录访问受保护接口应返回 401 或跳转登录页。 diff --git a/frontend/.env.example b/frontend/.env.example index 548c1d4d..b349d2ff 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,6 +3,8 @@ POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000 NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= POLYWEATHER_AUTH_ENABLED=false +# If true: middleware forces login; if false: optional login mode (guests allowed). +POLYWEATHER_AUTH_REQUIRED=false # Optional dashboard guard (Next.js middleware) # If set, open dashboard with: /?access_token= POLYWEATHER_DASHBOARD_ACCESS_TOKEN= diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 32b5532d..4007aae5 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -11,6 +11,7 @@ import { Copy, KeyRound, Loader2, + LogIn, LogOut, RefreshCw, ShieldCheck, @@ -28,6 +29,7 @@ type AuthMeResponse = { user_id?: string | null; email?: string | null; entitlement_mode?: string | null; + auth_required?: boolean; subscription_required?: boolean; subscription_active?: boolean | null; }; @@ -130,6 +132,7 @@ export function AccountCenter() { }; const userId = backend?.user_id || user?.id || ""; + const isAuthenticated = Boolean(userId); const email = backend?.email || user?.email || ""; const providerRaw = normalizeProvider(user); const provider = providerRaw ? providerRaw.toUpperCase() : t("account.na"); @@ -143,6 +146,8 @@ export function AccountCenter() { const modeLabel = useMemo(() => { const mode = String(backend?.entitlement_mode || "").trim().toLowerCase(); + if (mode === "supabase_required") return t("account.mode.supabaseRequired"); + if (mode === "supabase_optional") return t("account.mode.supabaseOptional"); if (mode === "supabase") return t("account.mode.supabase"); if (mode === "legacy_token") return t("account.mode.legacy"); if (mode === "disabled") return t("account.mode.disabled"); @@ -192,10 +197,17 @@ export function AccountCenter() { {refreshing ? : } {t("account.refresh")} - + {isAuthenticated ? ( + + ) : ( + + + {t("account.signIn")} + + )} @@ -205,24 +217,33 @@ export function AccountCenter() {

{displayName}

{email || t("account.na")}

- - - {t("account.authenticated")} - - {backend?.subscription_active ? ( - - - {t("account.subscriptionActive")} - - ) : backend?.subscription_required ? ( - - - {t("account.subscriptionRequired")} - + {isAuthenticated ? ( + <> + + + {t("account.authenticated")} + + {backend?.subscription_active ? ( + + + {t("account.subscriptionActive")} + + ) : backend?.subscription_required ? ( + + + {t("account.subscriptionRequired")} + + ) : ( + + + {t("account.subscriptionUnknown")} + + )} + ) : ( - {t("account.subscriptionUnknown")} + {t("account.guest")} )}
@@ -259,7 +280,9 @@ export function AccountCenter() {
{backend?.authenticated ? t("account.backend.ok") - : t("account.backend.fail")} + : backend?.auth_required + ? t("account.backend.fail") + : t("account.guest")}
@@ -328,4 +351,3 @@ export function AccountCenter() { ); } - diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts index 4525828b..7d863377 100644 --- a/frontend/lib/i18n.ts +++ b/frontend/lib/i18n.ts @@ -114,12 +114,14 @@ const MESSAGES: Record> = { "account.subtitle": "查看身份、权限与 Bot 绑定信息", "account.backDashboard": "返回看板", "account.refresh": "刷新", + "account.signIn": "登录 / 注册", "account.signOut": "退出登录", "account.loading": "正在同步账户信息...", "account.error": "加载失败: {message}", "account.updatedAt": "最近同步: {time}", "account.guestName": "PolyWeather 用户", "account.authenticated": "已登录", + "account.guest": "游客模式", "account.subscriptionActive": "订阅有效", "account.subscriptionRequired": "需要订阅", "account.subscriptionUnknown": "订阅状态未知", @@ -138,6 +140,8 @@ const MESSAGES: Record> = { "account.field.bindCommand": "绑定命令", "account.field.bindHint": "将下面命令发送到 Telegram Bot,可把网页账户与机器人权限绑定。", + "account.mode.supabaseRequired": "Supabase 强制登录", + "account.mode.supabaseOptional": "Supabase 可选登录", "account.mode.supabase": "Supabase 会话鉴权", "account.mode.legacy": "Legacy Token 鉴权", "account.mode.disabled": "未启用权限校验", @@ -266,12 +270,14 @@ const MESSAGES: Record> = { "account.subtitle": "Review identity, access status, and bot binding info", "account.backDashboard": "Back to Dashboard", "account.refresh": "Refresh", + "account.signIn": "Sign in / Sign up", "account.signOut": "Sign out", "account.loading": "Syncing account snapshot...", "account.error": "Failed to load: {message}", "account.updatedAt": "Last synced: {time}", "account.guestName": "PolyWeather User", "account.authenticated": "Authenticated", + "account.guest": "Guest Mode", "account.subscriptionActive": "Subscription active", "account.subscriptionRequired": "Subscription required", "account.subscriptionUnknown": "Subscription unknown", @@ -290,6 +296,8 @@ const MESSAGES: Record> = { "account.field.bindCommand": "Binding command", "account.field.bindHint": "Send this command to the Telegram bot to bind web account identity.", + "account.mode.supabaseRequired": "Supabase required auth", + "account.mode.supabaseOptional": "Supabase optional auth", "account.mode.supabase": "Supabase session auth", "account.mode.legacy": "Legacy token auth", "account.mode.disabled": "Auth guard disabled", diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 18bd219e..a1bba42d 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -5,10 +5,19 @@ import { } from "@/lib/supabase/server"; const SESSION_COOKIE = "polyweather_entitlement"; + +function readEnvBool(name: string, fallback: boolean) { + const raw = process.env[name]; + if (raw == null) return fallback; + return String(raw).trim().toLowerCase() === "true"; +} + const SUPABASE_AUTH_ENABLED = - String(process.env.POLYWEATHER_AUTH_ENABLED || "") - .trim() - .toLowerCase() === "true"; + readEnvBool("POLYWEATHER_AUTH_ENABLED", false); +const SUPABASE_AUTH_REQUIRED = readEnvBool( + "POLYWEATHER_AUTH_REQUIRED", + SUPABASE_AUTH_ENABLED, +); function isStaticAsset(pathname: string) { return ( @@ -114,6 +123,17 @@ async function handleSupabaseAuthGate(request: NextRequest) { return NextResponse.redirect(loginUrl); } +async function handleSupabaseOptionalSession(request: NextRequest) { + const response = NextResponse.next({ + request: { + headers: request.headers, + }, + }); + const supabase = createSupabaseMiddlewareClient(request, response); + await supabase.auth.getUser(); + return response; +} + export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; if (isStaticAsset(pathname)) { @@ -121,7 +141,10 @@ export async function middleware(request: NextRequest) { } if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) { - return handleSupabaseAuthGate(request); + if (SUPABASE_AUTH_REQUIRED) { + return handleSupabaseAuthGate(request); + } + return handleSupabaseOptionalSession(request); } return handleLegacyTokenGate(request); } diff --git a/web/app.py b/web/app.py index 24003048..7be6d74f 100644 --- a/web/app.py +++ b/web/app.py @@ -91,6 +91,10 @@ def _env_bool(name: str, default: bool = False) -> bool: _ENTITLEMENT_GUARD_ENABLED = _env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False) _ENTITLEMENT_HEADER = "x-polyweather-entitlement" _ENTITLEMENT_TOKEN = (os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip() +_SUPABASE_AUTH_REQUIRED = _env_bool( + "POLYWEATHER_AUTH_REQUIRED", + SUPABASE_ENTITLEMENT.enabled, +) def _legacy_service_token_valid(request: Request) -> bool: @@ -100,10 +104,26 @@ def _legacy_service_token_valid(request: Request) -> bool: return bool(_ENTITLEMENT_TOKEN and token == _ENTITLEMENT_TOKEN) +def _bind_optional_supabase_identity(request: Request) -> None: + if not SUPABASE_ENTITLEMENT.configured: + return + access_token = extract_bearer_token(request.headers.get("authorization")) + if not access_token: + return + identity = SUPABASE_ENTITLEMENT.get_identity(access_token) + if not identity: + return + request.state.auth_user_id = identity.user_id + request.state.auth_email = identity.email + + def _assert_entitlement(request: Request) -> None: if SUPABASE_ENTITLEMENT.enabled: if _legacy_service_token_valid(request): return + if not _SUPABASE_AUTH_REQUIRED: + _bind_optional_supabase_identity(request) + return if not SUPABASE_ENTITLEMENT.configured: raise HTTPException( status_code=503, @@ -1003,28 +1023,36 @@ async def city_history(request: Request, name: str): @app.get("/api/auth/me") async def auth_me(request: Request): _assert_entitlement(request) + _bind_optional_supabase_identity(request) + user_id = getattr(request.state, "auth_user_id", None) subscription_required = bool( - SUPABASE_ENTITLEMENT.enabled and SUPABASE_ENTITLEMENT.require_subscription + SUPABASE_ENTITLEMENT.enabled + and _SUPABASE_AUTH_REQUIRED + and SUPABASE_ENTITLEMENT.require_subscription ) subscription_active = None if SUPABASE_ENTITLEMENT.enabled and user_id: try: - subscription_active = SUPABASE_ENTITLEMENT.has_active_subscription(user_id) + if SUPABASE_ENTITLEMENT.require_subscription: + subscription_active = SUPABASE_ENTITLEMENT.has_active_subscription(user_id) except Exception: subscription_active = None return { - "authenticated": True, + "authenticated": bool(user_id), "user_id": user_id, "email": getattr(request.state, "auth_email", None), "entitlement_mode": ( - "supabase" + "supabase_required" + if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED + else "supabase_optional" if SUPABASE_ENTITLEMENT.enabled else "legacy_token" if _ENTITLEMENT_GUARD_ENABLED else "disabled" ), + "auth_required": bool(SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED), "subscription_required": subscription_required, "subscription_active": subscription_active, }