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,247 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
)
|
||||
|
||||
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
|
||||
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
|
||||
// provider; the access_token still flows back to the browser unchanged.
|
||||
|
||||
type authConfigResp struct {
|
||||
AuthMode string `json:"auth_mode"`
|
||||
AuthorizeURL string `json:"authorize_url"`
|
||||
// TokenURL is published so native clients (the desktop app) can run their
|
||||
// own loopback PKCE flow directly against the provider, reusing this same
|
||||
// client_id. The browser doesn't need it — it proxies through /api/auth/exchange.
|
||||
TokenURL string `json:"token_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
Scopes string `json:"scopes"`
|
||||
}
|
||||
|
||||
// handleAuthConfig publishes everything the frontend needs to start the PKCE
|
||||
// authorize redirect. No auth required (it's all public OIDC metadata).
|
||||
func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, authConfigResp{
|
||||
AuthMode: s.cfg.AuthMode,
|
||||
AuthorizeURL: s.cfg.OIDCAuthorizeURL,
|
||||
TokenURL: s.cfg.OIDCTokenURL,
|
||||
ClientID: s.cfg.OIDCClientID,
|
||||
RedirectURI: s.cfg.OIDCRedirectURI,
|
||||
Scopes: s.cfg.OIDCScopes,
|
||||
})
|
||||
}
|
||||
|
||||
type exchangeReq struct {
|
||||
Code string `json:"code"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
}
|
||||
|
||||
type tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
type userResp struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
type exchangeResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User userResp `json:"user"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
|
||||
var req exchangeReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if req.Code == "" || req.CodeVerifier == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing code or code_verifier"})
|
||||
return
|
||||
}
|
||||
if s.cfg.OIDCTokenURL == "" {
|
||||
writeJSON(w, http.StatusInternalServerError,
|
||||
map[string]string{"error": "OIDC token URL not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", req.Code)
|
||||
form.Set("code_verifier", req.CodeVerifier)
|
||||
form.Set("client_id", s.cfg.OIDCClientID)
|
||||
form.Set("redirect_uri", s.cfg.OIDCRedirectURI)
|
||||
// Casdoor's "cdrop" application is a confidential client; PKCE is layered
|
||||
// on top of the standard secret. Skip the secret if not configured to keep
|
||||
// public-client OIDC providers happy.
|
||||
if s.cfg.OIDCClientSecret != "" {
|
||||
form.Set("client_secret", s.cfg.OIDCClientSecret)
|
||||
}
|
||||
|
||||
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
|
||||
if err != nil {
|
||||
slog.Warn("oidc exchange failed", "err", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := extractUser(tr.IDToken, tr.AccessToken)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway,
|
||||
map[string]string{"error": "extract user: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, exchangeResp{
|
||||
AccessToken: tr.AccessToken,
|
||||
RefreshToken: tr.RefreshToken,
|
||||
ExpiresIn: tr.ExpiresIn,
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
type refreshReq struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type refreshResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req refreshReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if req.RefreshToken == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing refresh_token"})
|
||||
return
|
||||
}
|
||||
if s.cfg.OIDCTokenURL == "" {
|
||||
writeJSON(w, http.StatusInternalServerError,
|
||||
map[string]string{"error": "OIDC token URL not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", req.RefreshToken)
|
||||
form.Set("client_id", s.cfg.OIDCClientID)
|
||||
if s.cfg.OIDCClientSecret != "" {
|
||||
form.Set("client_secret", s.cfg.OIDCClientSecret)
|
||||
}
|
||||
|
||||
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
|
||||
if err != nil {
|
||||
slog.Warn("oidc refresh failed", "err", err)
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, refreshResp{
|
||||
AccessToken: tr.AccessToken,
|
||||
RefreshToken: tr.RefreshToken,
|
||||
ExpiresIn: tr.ExpiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := oidcHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oidc status %d: %s", resp.StatusCode, truncate(string(body), 200))
|
||||
}
|
||||
|
||||
var tr tokenResp
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return nil, fmt.Errorf("oidc malformed: %w", err)
|
||||
}
|
||||
if tr.AccessToken == "" {
|
||||
return nil, fmt.Errorf("oidc returned no access_token")
|
||||
}
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
// extractUser parses {sub, preferred_username | name} from the id_token if
|
||||
// available, else falls back to access_token. We DON'T verify the signature
|
||||
// here — the backend's auth middleware verifies access_token on every API
|
||||
// call via the JWKS cache, so any tamper would surface there.
|
||||
func extractUser(idToken, accessToken string) (userResp, error) {
|
||||
pick := idToken
|
||||
if pick == "" {
|
||||
pick = accessToken
|
||||
}
|
||||
parsed, err := jwt.ParseSigned(pick, []jose.SignatureAlgorithm{
|
||||
jose.RS256, jose.RS384, jose.RS512,
|
||||
jose.ES256, jose.ES384, jose.ES512,
|
||||
jose.HS256,
|
||||
})
|
||||
if err != nil {
|
||||
return userResp{}, err
|
||||
}
|
||||
var std jwt.Claims
|
||||
custom := map[string]any{}
|
||||
if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil {
|
||||
return userResp{}, err
|
||||
}
|
||||
u := userResp{ID: std.Subject}
|
||||
if v, ok := custom["preferred_username"].(string); ok && v != "" {
|
||||
u.Name = v
|
||||
} else if v, ok := custom["name"].(string); ok && v != "" {
|
||||
u.Name = v
|
||||
} else if v, ok := custom["email"].(string); ok && v != "" {
|
||||
u.Name = v
|
||||
} else {
|
||||
u.Name = u.ID
|
||||
}
|
||||
// Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段;
|
||||
// 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。
|
||||
if v, ok := custom["avatar"].(string); ok && v != "" {
|
||||
u.Avatar = v
|
||||
} else if v, ok := custom["picture"].(string); ok && v != "" {
|
||||
u.Avatar = v
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user