cdrop — 跨 OS 剪贴板与文件传输服务
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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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(/=+$/, "");
|
||||
}
|
||||
Reference in New Issue
Block a user