5c28ddf9b8
让 web 前端可「添加到主屏幕」以独立窗口启动——iOS 上免证书的类原生体验(也使 Android/桌面出现安装提示)。 - site.webmanifest 补全可安装字段:id / start_url / scope / description / orientation;icons 拆出独立 any 与 maskable 条目。 - index.html 加 iOS 独立窗口 meta:apple-mobile-web-app-capable / status-bar-style / title + application-name(旧版 iOS 仍读这些才无 Safari chrome 启动)。 - public/sw.js(新):仅缓存同源静态壳,**绝不拦截 /api/***(SSE /api/hub/events、剪贴板、信令、中继需要实时不缓冲的网络连接,直接放行);跨源(字体/CDN)也放行。导航走 network-first(新部署即取新 HTML),静态资源 stale-while-revalidate。cdrop 离线无意义,故此 SW 只为可安装 + 秒开壳。 - main.tsx:SW 注册仅在浏览器 + prod;dev 下干扰 Vite HMR、桌面壳(Wails WebView,wails:// / loopback origin)SW 行为不可靠,故 isDesktop() 跳过。 - auth.ts:PKCE verifier / state 从 sessionStorage 改 localStorage。iOS 独立 PWA 可能在独立上下文跑完 OIDC 往返、回跳时重启 PWA 而清空 sessionStorage,导致 verifier 丢失登录失败;localStorage 跨上下文存活。verifier 在 callback 即消费并删除、且无配对 code 无用,落盘窗口的安全代价可忽略。
227 lines
8.0 KiB
TypeScript
227 lines
8.0 KiB
TypeScript
import { useAppStore, type User } from "../../store";
|
||
import { t } from "../../i18n";
|
||
import { apiFetch } from "../../net/api";
|
||
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
|
||
|
||
// ---- dev mode -------------------------------------------------------------
|
||
|
||
// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage),
|
||
// stamp the store with a synthetic User and the build-time dev token.
|
||
// This bypasses Casdoor entirely. The middleware on the backend accepts
|
||
// any X-Dev-User as long as the bearer matches CDROP_DEV_TOKEN.
|
||
export function loginDev(searchParams: URLSearchParams): User
|
||
{
|
||
const devUser = searchParams.get("dev_user")
|
||
?? window.localStorage.getItem("cdrop.dev_user")
|
||
?? "dev-user";
|
||
window.localStorage.setItem("cdrop.dev_user", devUser);
|
||
|
||
const token = import.meta.env.VITE_CDROP_DEV_TOKEN;
|
||
if (!token)
|
||
{
|
||
throw new Error(t("errors.devTokenMissing"));
|
||
}
|
||
|
||
const user: User = { id: devUser, name: devUser };
|
||
useAppStore.getState().setAuth({ accessToken: token, user });
|
||
return user;
|
||
}
|
||
|
||
export function logout()
|
||
{
|
||
// Fire-and-forget 通知后端立刻把本设备从 Hub 踢掉 + 广播 presence,让对端
|
||
// 不必等 30s 宽限期。apiFetch 同步抓取 access_token 后才让出(fetch 已发出),
|
||
// 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略
|
||
// ——只是体验降级回宽限期路径,不影响登出本身。
|
||
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
|
||
useAppStore.getState().clearAuth();
|
||
// 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。
|
||
if (isDesktop()) { clearDesktopSession(); }
|
||
}
|
||
|
||
// ---- prod (OIDC PKCE) -----------------------------------------------------
|
||
|
||
interface AuthConfig
|
||
{
|
||
auth_mode: "dev" | "prod";
|
||
authorize_url: string;
|
||
client_id: string;
|
||
redirect_uri: string;
|
||
scopes: string;
|
||
}
|
||
|
||
let cachedConfig: AuthConfig | null = null;
|
||
|
||
export async function fetchAuthConfig(): Promise<AuthConfig>
|
||
{
|
||
if (cachedConfig) { return cachedConfig; }
|
||
const r = await fetch("/api/auth/config");
|
||
if (!r.ok) { throw new Error(`auth config ${r.status}`); }
|
||
cachedConfig = await r.json() as AuthConfig;
|
||
return cachedConfig;
|
||
}
|
||
|
||
const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier";
|
||
const OAUTH_STATE_KEY = "cdrop.oauth_state";
|
||
|
||
// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash them
|
||
// in localStorage, navigate the browser to the provider's /authorize endpoint.
|
||
// The callback page completes the flow.
|
||
//
|
||
// localStorage (not sessionStorage): a standalone PWA on iOS may run the
|
||
// provider round-trip in a separate context and relaunch the PWA on the redirect
|
||
// back, which wipes sessionStorage and breaks login. localStorage survives that.
|
||
// The verifier is consumed + removed immediately on callback and is useless
|
||
// without the matching auth code, so persisting it for the round-trip is fine.
|
||
export async function loginProd(): Promise<void>
|
||
{
|
||
const cfg = await fetchAuthConfig();
|
||
if (cfg.auth_mode !== "prod")
|
||
{
|
||
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
|
||
}
|
||
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
|
||
{
|
||
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
|
||
}
|
||
|
||
const verifier = generateRandomBase64url(32);
|
||
const challenge = await sha256Base64url(verifier);
|
||
const state = generateRandomBase64url(16);
|
||
|
||
localStorage.setItem(PKCE_VERIFIER_KEY, verifier);
|
||
localStorage.setItem(OAUTH_STATE_KEY, state);
|
||
|
||
const params = new URLSearchParams({
|
||
client_id: cfg.client_id,
|
||
redirect_uri: cfg.redirect_uri,
|
||
response_type: "code",
|
||
scope: cfg.scopes || "openid profile email",
|
||
state,
|
||
code_challenge: challenge,
|
||
code_challenge_method: "S256",
|
||
});
|
||
window.location.href = `${cfg.authorize_url}?${params}`;
|
||
}
|
||
|
||
interface ExchangeResp
|
||
{
|
||
access_token: string;
|
||
refresh_token: string;
|
||
expires_in: number;
|
||
user: { id: string; name: string; avatar?: string };
|
||
}
|
||
|
||
// completeOAuthLogin runs on /oauth/callback after the provider redirects back.
|
||
// Verifies state (CSRF), trades code+verifier for tokens via the backend
|
||
// proxy, and stamps the store with the user identity.
|
||
//
|
||
// Idempotent:若 store 中已有 user + accessToken(典型场景:useEffect 在 React
|
||
// 渲染管线中重入,首次已成功 setAuth + 消耗 localStorage,二次入场不能再被
|
||
// "PKCE verifier missing" / "state mismatch" 当作失败处理)→ 当作 noop 直接
|
||
// resolve,由上层 navigate 接管。
|
||
export async function completeOAuthLogin(code: string, state: string): Promise<void>
|
||
{
|
||
const cur = useAppStore.getState();
|
||
if (cur.user && cur.accessToken)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const expectedState = localStorage.getItem(OAUTH_STATE_KEY);
|
||
if (!expectedState || state !== expectedState)
|
||
{
|
||
throw new Error("OAuth state mismatch (possible CSRF or stale tab)");
|
||
}
|
||
const verifier = localStorage.getItem(PKCE_VERIFIER_KEY);
|
||
if (!verifier)
|
||
{
|
||
throw new Error("PKCE verifier missing — restart the login flow");
|
||
}
|
||
|
||
const r = await fetch("/api/auth/exchange", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ code, code_verifier: verifier }),
|
||
});
|
||
if (!r.ok)
|
||
{
|
||
const text = await r.text().catch(() => r.statusText);
|
||
throw new Error(`token exchange ${r.status}: ${text}`);
|
||
}
|
||
const data = await r.json() as ExchangeResp;
|
||
|
||
localStorage.removeItem(PKCE_VERIFIER_KEY);
|
||
localStorage.removeItem(OAUTH_STATE_KEY);
|
||
|
||
useAppStore.getState().setAuth({
|
||
accessToken: data.access_token,
|
||
refreshToken: data.refresh_token,
|
||
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
|
||
});
|
||
}
|
||
|
||
// refreshTokens proxies to /api/auth/refresh. Returns true on success.
|
||
// api.ts calls this lazily when a request comes back 401.
|
||
export async function refreshTokens(): Promise<boolean>
|
||
{
|
||
const store = useAppStore.getState();
|
||
if (store.authMode !== "prod") { return false; }
|
||
|
||
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),故
|
||
// 无需 store.refreshToken,直接无参调用。
|
||
if (isDesktop())
|
||
{
|
||
const res = await desktopRefresh();
|
||
if (!res?.access_token) { return false; }
|
||
const cur = useAppStore.getState();
|
||
if (!cur.user) { return false; }
|
||
cur.setAuth({ accessToken: res.access_token, user: cur.user });
|
||
return true;
|
||
}
|
||
|
||
const refreshToken = store.refreshToken;
|
||
if (!refreshToken) { return false; }
|
||
|
||
const r = await fetch("/api/auth/refresh", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||
});
|
||
if (!r.ok) { return false; }
|
||
const data = await r.json() as { access_token: string; refresh_token?: string };
|
||
if (!data.access_token) { return false; }
|
||
|
||
const cur = useAppStore.getState();
|
||
if (!cur.user) { return false; }
|
||
cur.setAuth({
|
||
accessToken: data.access_token,
|
||
refreshToken: data.refresh_token ?? refreshToken,
|
||
user: cur.user,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// ---- PKCE helpers ---------------------------------------------------------
|
||
|
||
function generateRandomBase64url(numBytes: number): string
|
||
{
|
||
const arr = new Uint8Array(numBytes);
|
||
crypto.getRandomValues(arr);
|
||
return base64urlEncode(arr);
|
||
}
|
||
|
||
async function sha256Base64url(input: string): Promise<string>
|
||
{
|
||
const buf = new TextEncoder().encode(input);
|
||
const hash = await crypto.subtle.digest("SHA-256", buf);
|
||
return base64urlEncode(new Uint8Array(hash));
|
||
}
|
||
|
||
function base64urlEncode(bytes: Uint8Array): string
|
||
{
|
||
let s = "";
|
||
for (let i = 0; i < bytes.length; i += 1) { s += String.fromCharCode(bytes[i]); }
|
||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||
}
|