f21fa5b5e8
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
221 lines
7.7 KiB
TypeScript
221 lines
7.7 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 the
|
||
// secrets in sessionStorage, navigate the browser to the provider's
|
||
// /authorize endpoint. The callback page completes the flow.
|
||
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);
|
||
|
||
sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier);
|
||
sessionStorage.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 + 消耗 sessionStorage,二次入场不能再被
|
||
// "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 = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||
if (!expectedState || state !== expectedState)
|
||
{
|
||
throw new Error("OAuth state mismatch (possible CSRF or stale tab)");
|
||
}
|
||
const verifier = sessionStorage.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;
|
||
|
||
sessionStorage.removeItem(PKCE_VERIFIER_KEY);
|
||
sessionStorage.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(/=+$/, "");
|
||
}
|