全局配置更新:OAuth 回调修复、支付安全加固、站点 URL 工具

- 新增 NEXT_PUBLIC_SITE_URL 支持及 site-url.ts 工具模块
- 修复 OAuth 回调域名:import.meta.env 统一读取站点 URL
- 支付 API 路由新增收款地址校验
- 后端支付服务更新
- middleware 清理
- 新增 paymentSecurity 测试
This commit is contained in:
2569718930@qq.com
2026-05-24 18:33:47 +08:00
parent 2be0b71018
commit 20c8395c0b
22 changed files with 259 additions and 88 deletions
+5
View File
@@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
# 生产环境必须设为 https://polyweather-pro.vercel.app
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
# false: 关闭登录能力,访客模式
+1 -1
View File
@@ -18,7 +18,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
# 生产环境必须设为 https://polyweather-pro.vercel.app
NEXT_PUBLIC_SITE_URL=
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -24,6 +25,8 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -24,6 +25,8 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -24,6 +25,8 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/intents`, {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
@@ -17,6 +18,8 @@ export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
method: "POST",
headers: auth.headers,
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -44,6 +45,8 @@ export async function DELETE(req: NextRequest) {
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
+11 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
import { getConfiguredSiteUrl } from "@/lib/site-url";
function normalizeNextPath(input: string | null) {
const fallback = "/";
@@ -11,6 +12,16 @@ function normalizeNextPath(input: string | null) {
}
export async function GET(request: NextRequest) {
const configuredSiteUrl = getConfiguredSiteUrl();
if (configuredSiteUrl) {
const canonicalOrigin = new URL(configuredSiteUrl).origin;
if (request.nextUrl.origin !== canonicalOrigin) {
const canonicalCallbackUrl = new URL(request.nextUrl.pathname, canonicalOrigin);
canonicalCallbackUrl.search = request.nextUrl.search;
return NextResponse.redirect(canonicalCallbackUrl);
}
}
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = nextPath;
@@ -29,4 +40,3 @@ export async function GET(request: NextRequest) {
return response;
}
+19 -8
View File
@@ -44,6 +44,10 @@ import {
getCurrentPaymentHost,
isPaymentHostAllowed,
} from "@/lib/payment-host";
import {
assertExpectedPaymentReceiver,
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
} from "@/lib/payment-receiver";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
@@ -1062,11 +1066,7 @@ export function AccountCenter() {
(resolvedSelectedTokenAddress.startsWith("0x")
? shortAddress(resolvedSelectedTokenAddress)
: "USDC");
const paymentReceiverAddress = String(
selectedPaymentToken?.receiver_contract ||
paymentConfig?.receiver_contract ||
"",
).toLowerCase();
const paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS;
const paymentWalletLabel = String(
selectedWallet ||
walletAddress ||
@@ -1348,6 +1348,15 @@ export function AccountCenter() {
options: ConnectBindOptions = {},
): Promise<boolean> => {
clearPaymentMessages();
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
),
);
return false;
}
if (!isAuthenticated) {
setPaymentError(copy.loginBeforeBind);
return false;
@@ -1621,9 +1630,7 @@ export function AccountCenter() {
const expectedReceiver = String(
latestConfig.receiver_contract || "",
).toLowerCase();
if (!expectedReceiver.startsWith("0x")) {
throw new Error("payment receiver contract is not configured");
}
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
if (
paymentConfig?.receiver_contract &&
String(paymentConfig.receiver_contract).toLowerCase() !==
@@ -1920,6 +1927,10 @@ export function AccountCenter() {
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
throw new Error("manual payment payload invalid");
}
assertExpectedPaymentReceiver(
direct.receiver_address,
"manual payment receiver",
);
setLastIntentId(intentId);
setManualPayment(direct);
setPaymentMethodTab("manual");
@@ -0,0 +1,76 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
const EXPECTED_RECEIVER = "0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a";
export function runTests() {
const projectRoot = process.cwd();
const receiverModulePath = path.join(projectRoot, "lib", "payment-receiver.ts");
const backendAuthPath = path.join(projectRoot, "lib", "backend-auth.ts");
const middlewarePath = path.join(projectRoot, "middleware.ts");
const accountCenterPath = path.join(
projectRoot,
"components",
"account",
"AccountCenter.tsx",
);
const paymentRoutes = [
"app/api/payments/wallets/challenge/route.ts",
"app/api/payments/wallets/verify/route.ts",
"app/api/payments/wallets/route.ts",
"app/api/payments/intents/route.ts",
"app/api/payments/intents/[intentId]/submit/route.ts",
"app/api/payments/intents/[intentId]/confirm/route.ts",
"app/api/payments/intents/[intentId]/validate/route.ts",
"app/api/payments/reconcile-latest/route.ts",
];
assert(
fs.existsSync(receiverModulePath),
"payment receiver guard module must exist",
);
const receiverSource = fs.readFileSync(receiverModulePath, "utf8");
assert(
receiverSource.includes(EXPECTED_RECEIVER),
"payment receiver guard must pin the production receiver address",
);
assert(
receiverSource.includes("assertExpectedPaymentReceiver"),
"payment receiver guard must expose an assertion helper",
);
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
assert(
accountCenterSource.includes("assertExpectedPaymentReceiver"),
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
);
assert(
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS"),
"AccountCenter must show the pinned payment receiver address in its payment guard",
);
const backendAuthSource = fs.readFileSync(backendAuthPath, "utf8");
assert(
backendAuthSource.includes("requireBackendAuthUser"),
"backend auth helper must expose a real-user requirement for payment mutations",
);
const middlewareSource = fs.readFileSync(middlewarePath, "utf8");
assert(
!middlewareSource.includes("/^bearer\\s+\\S+/i.test(authHeader)") &&
!middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"),
"middleware must not treat the mere presence of a bearer token as authenticated",
);
for (const route of paymentRoutes) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert(
routeSource.includes("requireBackendAuthUser"),
`${route} must reject payment mutations without a real Supabase user`,
);
}
}
+8 -3
View File
@@ -17,6 +17,7 @@ import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url";
import { useI18n } from "@/hooks/useI18n";
type Mode = "login" | "signup";
@@ -38,8 +39,8 @@ export function LoginClient({ nextPath }: LoginClientProps) {
const supabaseReady = hasSupabasePublicEnv();
const siteOrigin =
process.env.NEXT_PUBLIC_SITE_URL?.trim() ||
(typeof window !== "undefined" ? window.location.origin : "");
getConfiguredSiteUrl() ||
(typeof window !== "undefined" ? window.location.origin : PRODUCTION_SITE_URL);
const isEn = locale === "en-US";
const copy = {
backHome: isEn ? "Back to Home" : "返回首页",
@@ -102,7 +103,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.resetPasswordForEmail(email.trim());
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent(
"/account",
)}`,
});
if (error) {
setErrorText(error.message);
return;
+10 -1
View File
@@ -94,4 +94,13 @@ export function applyAuthResponseCookies(
return target;
}
export function requireBackendAuthUser(auth: HeaderBuildResult) {
if (auth.authUserId) return null;
return applyAuthResponseCookies(
NextResponse.json(
{ error: "Authentication required", detail: "Supabase user required" },
{ status: 401 },
),
auth.response,
);
}
+19
View File
@@ -0,0 +1,19 @@
export const EXPECTED_PAYMENT_RECEIVER_ADDRESS =
"0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a";
export function normalizePaymentReceiver(address: string | null | undefined) {
return String(address || "").trim().toLowerCase();
}
export function assertExpectedPaymentReceiver(
address: string | null | undefined,
label = "payment receiver",
) {
const normalized = normalizePaymentReceiver(address);
if (normalized !== EXPECTED_PAYMENT_RECEIVER_ADDRESS) {
throw new Error(
`${label} mismatch: expected ${EXPECTED_PAYMENT_RECEIVER_ADDRESS}, got ${normalized || "empty"}`,
);
}
return normalized;
}
+7
View File
@@ -0,0 +1,7 @@
export const PRODUCTION_SITE_URL = "https://polyweather-pro.vercel.app";
export function getConfiguredSiteUrl() {
const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim();
if (configured) return configured;
return process.env.NODE_ENV === "production" ? PRODUCTION_SITE_URL : "";
}
-6
View File
@@ -106,12 +106,6 @@ async function handleSupabaseAuthGate(request: NextRequest) {
if (isPublicPage(pathname) || isPublicApi(pathname)) {
return NextResponse.next();
}
if (pathname.startsWith("/api/")) {
const authHeader = String(request.headers.get("authorization") || "").trim();
if (/^bearer\s+\S+/i.test(authHeader)) {
return NextResponse.next();
}
}
const response = NextResponse.next({
request: {