Files
Commilitia-Drop/web/src/features/auth/auth.ts
T
admin 5c28ddf9b8 web:PWA 安装支持(manifest + iOS meta + service worker)+ PKCE 改 localStorage
让 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 无用,落盘窗口的安全代价可忽略。
2026-06-15 22:14:18 +08:00

227 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 只在 GoJS 不持有),故
// 无需 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(/=+$/, "");
}