diff --git a/.env.example b/.env.example index 49d73b7..3f651a7 100644 --- a/.env.example +++ b/.env.example @@ -3,21 +3,22 @@ CDROP_AUTH_MODE=dev CDROP_DEV_TOKEN=replace-with-32-byte-random-base64 -# prod 模式所需(填入你的 OIDC provider,如自建 Casdoor / Keycloak) +# prod 模式所需(Auth Broker 路径 A)。cdrop 把身份与会话生命周期委托给 broker:每个受控 +# 请求在边缘由 broker /verify(经 Caddy)鉴权并注入 X-Auth-* 头;扫码批准 / 设备续期 / 吊销 +# 经 broker 内部 + 公开 API。cdrop 不再自做 OIDC、不存任何凭证。 # CDROP_AUTH_MODE=prod -# CDROP_OIDC_AUTHORIZE_URL=https://your-idp.example/login/oauth/authorize -# CDROP_OIDC_TOKEN_URL=https://your-idp.example/api/login/oauth/access_token -# CDROP_OIDC_JWKS_URL=https://your-idp.example/.well-known/jwks -# CDROP_OIDC_ISSUER=https://your-idp.example/ -# prod 强制非空:填你的 OAuth client_id(多值逗号分隔,web + 桌面端共用时填同一个) -# CDROP_OIDC_AUDIENCE=your-client-id -# CDROP_OIDC_CLIENT_ID=your-client-id -# CDROP_OIDC_REDIRECT_URI=https://your-domain.example/oauth/callback -# CDROP_OIDC_SCOPES=openid profile email -# prod 强制非空:浏览器免重登会话的 refresh_token 落盘加密密钥(任意长度高熵串, -# 内部 SHA-256 派生为 32 字节 AES-256 密钥)。留空则 prod 启动期校验失败、拒启动。 -# CDROP_SESSION_SECRET= -# CDROP_HS256_SECRET= +# broker 内网地址(docker 内网直连,绝不经公网反代——以保 /internal/* 与 /refresh 可达)。 +# prod 强制非空。 +# CDROP_BROKER_BASE_URL=http://broker-internal-ip:8080 +# broker /internal/* 端点共享密钥(作 X-Internal-Key 发送)。prod 强制非空,向运维取、勿硬编码。 +# CDROP_BROKER_INTERNAL_KEY= +# broker 公开源(浏览器全局 SSO 登录跳转 / 原生客户端设备授权流的目标)。留空则 +# /api/auth/login 与原生登录不可用。 +# CDROP_BROKER_PUBLIC_URL=https://sso.your-domain.example +# 本应用在 broker apps 注册表里的 key(默认 cdrop)。 +# CDROP_BROKER_APP=cdrop +# 部署公开源(拼扫码 QR 链接 / CSRF Origin 校验 / Web Push 默认联系标识)。 +# CDROP_PUBLIC_URL=https://drop.your-domain.example # CDROP_TURN_URL=stun:your-stun.example:3478 # 可选:Web Push(VAPID)推送通知。用 `just vapid-keygen` 生成一对密钥;二者须同时 @@ -27,22 +28,20 @@ CDROP_DEV_TOKEN=replace-with-32-byte-random-base64 # CDROP_VAPID_PRIVATE_KEY= # CDROP_VAPID_SUBJECT=mailto:you@your-domain.example -# 可选:扫码登录(QR)与 cdrop 自签会话。SESSION_SECRET 已设时默认开启;下列均有默认值, -# 不配即用默认,各项可单独调整 / 关闭对应行为。 -# CDROP_QR_LOGIN_ENABLED=true # 关闭则 /api/auth/qr/* 返回 503 -# CDROP_QR_REQUEST_TTL_SECONDS=120 # 二维码 / 登录请求有效期(秒) -# CDROP_QR_GUEST_TTL_SECONDS=3600 # 仅此次(受限访客)会话 TTL(秒) -# CDROP_QR_PERSIST_TTL_HOURS=168 # 信任此设备(持久)会话 TTL(小时) -# CDROP_SESSION_TOKEN_TTL_SECONDS=900 # cdrop 自签 access token TTL(秒) -# CDROP_STEP_UP_ENABLED=false # 批准扫码设备等敏感动作要求 provider 再认证 -# CDROP_STEP_UP_MAX_AGE_SECONDS=300 # step-up 的 auth_time 新鲜度窗口(秒) -# CDROP_ACCOUNT_MATCH_CLAIM=email # 写入 accounts.match_key 的 JWT claim +# 可选:扫码登录(QR)+ 按档令牌 TTL(批准后经 broker 委托签发对应 tier 的会话)。下列均有 +# 默认值,不配即用默认。 +# CDROP_QR_LOGIN_ENABLED=true # 关闭则 /api/auth/qr/* 返回 503 +# CDROP_QR_REQUEST_TTL_SECONDS=120 # 二维码 / 登录请求有效期(秒) +# CDROP_FULL_ACCESS_TTL_SECONDS=900 # full 设备 access token TTL(秒) +# CDROP_FULL_REFRESH_TTL_SECONDS=604800 # full 设备 refresh TTL(秒,7 天,滑动) +# CDROP_GUEST_ACCESS_TTL_SECONDS=900 # guest(访客借用)access token TTL(秒) +# CDROP_GUEST_REFRESH_TTL_SECONDS=86400 # guest refresh TTL(秒,1 天,滑动) # 通用 CDROP_DB_PATH=./cdrop.db CDROP_LISTEN=:8080 -# Device row max age (sliding via auth-middleware UPSERT and SSE keepalive). -# Bound by brief §2 refresh_token sliding window (196h ≈ 8 days). Default 196. +# 设备行最大存活(鉴权中间件 TouchDevice 与 SSE keepalive 滑动刷新 last_seen)。 +# 不应短于 broker 会话的 refresh 窗口(full 7 天)。默认 196 小时。 CDROP_DEVICE_TTL_HOURS=196 # Cloudflare Realtime TURN(可选;不配则 /api/calls/credentials 返回 STUN-only fallback)。 diff --git a/cmd/cdropd/main.go b/cmd/cdropd/main.go index 7a63d44..cfc55d4 100644 --- a/cmd/cdropd/main.go +++ b/cmd/cdropd/main.go @@ -84,7 +84,7 @@ func main() { go transfer.RunSweeper(ctx, transfers) go relayMgr.RunReaper(ctx) go jwtauth.RunDeviceSweeper(ctx, queries, time.Duration(cfg.DeviceTTLHours)*time.Hour) - go httpapi.RunWebSessionReaper(ctx, queries) + go httpapi.RunLoginRequestReaper(ctx, queries) if cfg.ClipboardTTLSec > 0 { go clipboard.RunSweeper(ctx, clip) } diff --git a/desktop/app.go b/desktop/app.go index 27c8523..faa20fc 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -13,26 +13,12 @@ import ( "cdrop-desktop/platform" ) -// loginResultFromToken decodes the identity out of the exchanged tokens and -// assembles the payload the WebView store consumes. Shared by login + refresh. -func loginResultFromToken(tok *platform.TokenResult) (*platform.LoginResult, error) { - user, err := platform.UserFromToken(tok.IDToken, tok.AccessToken) - if err != nil { - return nil, err - } - return &platform.LoginResult{ - AccessToken: tok.AccessToken, - RefreshToken: tok.RefreshToken, - ExpiresIn: tok.ExpiresIn, - User: user, - }, nil -} - // App is the Wails application context bound to the WebView. type App struct { ctx context.Context apiBase string token *platform.TokenResult // in-memory copy; refresh_token persists in the OS secret store (see session.go) + user platform.UserInfo // display identity from 代铸; preserved across refresh (machine tokens are nameless) quitting bool // set by the menu bar "Quit" so beforeClose allows the real exit startHidden bool // set by main when launched via autostart: come up to the menu bar, no window @@ -58,6 +44,7 @@ func (a *App) startup(ctx context.Context) { RefreshToken: s.RefreshToken, ExpiresIn: s.ExpiresIn, } + a.user = s.User } // Keep the autostart entry in sync with this binary: when it's enabled, // re-apply so an entry written by an older build (no --hidden flag) or one @@ -222,17 +209,40 @@ func (a *App) StartLogin() { flow := platform.NewFlow(cfg, func(u string) { runtime.BrowserOpenURL(a.ctx, u) }) - tok, err := flow.Login(a.ctx) + // Step 1: device-authorize → a bootstrap machine token (proves identity, no meta). + boot, err := flow.Login(a.ctx) if err != nil { runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) return } - res, err := loginResultFromToken(tok) + // Step 2: 代铸 the bootstrap token into a cdrop-managed device session bound to this + // device's stable device_id, so the desktop is managed exactly like a browser. The + // device session's tokens supersede the bootstrap (which is discarded, left to expire). + deviceID, err := platform.ResolveDeviceID() if err != nil { runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) return } - a.token = tok + ds, err := platform.MintDeviceSession(a.ctx, a.apiBase, boot.AccessToken, deviceID, platform.ResolveDeviceName(), platform.DeviceType()) + if err != nil { + runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) + return + } + // Identity from the verified 代铸 response (X-Auth-Subject / X-Auth-Name / X-Auth-Avatar), + // so the real display name and picture show rather than the subject UUID a nameless + // machine token yields. + user := platform.UserInfo{ID: ds.UserID, Name: ds.Name, Avatar: ds.Avatar} + if user.Name == "" { + user.Name = user.ID + } + res := &platform.LoginResult{ + AccessToken: ds.AccessToken, + RefreshToken: ds.RefreshToken, + ExpiresIn: ds.ExpiresIn, + User: user, + } + a.token = &platform.TokenResult{AccessToken: ds.AccessToken, RefreshToken: ds.RefreshToken, ExpiresIn: ds.ExpiresIn} + a.user = user if err := platform.SaveSession(*res); err != nil { runtime.LogWarningf(a.ctx, "persist session failed: %v", err) } @@ -262,9 +272,15 @@ func (a *App) Refresh() (*platform.SessionView, error) { if tok.RefreshToken == "" { tok.RefreshToken = a.token.RefreshToken // providers that don't rotate: keep the old one } - res, err := loginResultFromToken(tok) - if err != nil { - return nil, err + // Preserve the identity established at login: the refreshed access token is a nameless + // machine token (it carries meta, not the display name), so re-deriving identity from it + // would regress the name to the subject UUID. The device session keeps its meta across + // rotation, so the device stays the same managed device. + res := &platform.LoginResult{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + ExpiresIn: tok.ExpiresIn, + User: a.user, } a.token = tok if err := platform.SaveSession(*res); err != nil { @@ -368,11 +384,11 @@ func (a *App) EffectiveDownloadDir() string { return platform.ResolveDownloadDir() } -// resolveOAuthConfig prefers explicit env overrides (dev / a pinned client); -// otherwise it fetches /api/auth/config from the backend and reuses the web -// app's client_id for the desktop's loopback PKCE flow (see desktop/PLAN.md §3). +// resolveOAuthConfig prefers explicit env overrides (dev / a pinned broker); +// otherwise it fetches /api/auth/config from the backend for the broker's public URL, +// then runs the broker device-authorization flow against it. func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) { - if env := oauthConfigFromEnv(); env.ClientID != "" { + if env := oauthConfigFromEnv(); env.BrokerURL != "" { return env, nil } return platform.FetchOAuthConfig(a.ctx, a.apiBase) @@ -380,10 +396,8 @@ func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) { func oauthConfigFromEnv() platform.OAuthConfig { return platform.OAuthConfig{ - AuthorizeURL: os.Getenv("CDROP_OAUTH_AUTHORIZE_URL"), - TokenURL: os.Getenv("CDROP_OAUTH_TOKEN_URL"), - ClientID: os.Getenv("CDROP_OAUTH_CLIENT_ID"), - Scopes: os.Getenv("CDROP_OAUTH_SCOPES"), + BrokerURL: os.Getenv("CDROP_BROKER_URL"), + App: envOr("CDROP_BROKER_APP", "cdrop"), } } diff --git a/desktop/main.go b/desktop/main.go index d1b25d5..57b5a40 100644 --- a/desktop/main.go +++ b/desktop/main.go @@ -44,20 +44,18 @@ func main() { // Tell the backend this is a native desktop client, not a browser, so the // device list shows the right kind. The page forwards it as X-Device-Type. - deviceType := "" - switch runtime.GOOS { - case "darwin": - deviceType = "macos" - case "windows": - deviceType = "windows" - case "linux": - deviceType = "linux" - } + deviceType := platform.DeviceType() // Restore the persisted login + device name by injecting them into index.html // at load — the wails:// scheme has no persistent WebView storage (see // session.go). session, _ := platform.LoadSession() + // Heal a stale display identity (older builds baked the subject UUID and no avatar into + // the on-disk session) from the authoritative /api/me, and re-persist, BEFORE injecting it + // — so the name + picture are correct on every launch instead of depending on a fragile + // per-launch WebView /api/me (the source of the intermittent UUID regression). Bounded + + // best-effort: offline keeps the persisted identity. + session = platform.HealIdentity(session, app.apiBase) // When the OS starts us at login, the autostart entry appends a --hidden flag // (see SetLaunchAtLogin). Come up straight to the menu bar / tray with no diff --git a/desktop/platform/appsettings.go b/desktop/platform/appsettings.go index b703653..be7f8ff 100644 --- a/desktop/platform/appsettings.go +++ b/desktop/platform/appsettings.go @@ -1,10 +1,13 @@ package platform import ( + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "os" "path/filepath" + "runtime" ) // DesktopConfig is the desktop-only preference set, persisted as JSON under the @@ -14,6 +17,44 @@ type DesktopConfig struct { LaunchAtLogin bool `json:"launch_at_login"` DeviceName string `json:"device_name"` // empty = use the hostname DownloadDir string `json:"download_dir"` // empty = system Downloads dir + // DeviceID is this device's stable opaque cdrop id — the broker `meta` and the + // session<->device join key. Minted once on first login and persisted so re-logins + // rotate the one managed device session (broker R2) instead of spawning duplicates. + DeviceID string `json:"device_id,omitempty"` +} + +// DeviceType maps the host OS to the cdrop device kind, shown in the unified device list +// and sent as device_type when 代铸ing this device's session. +func DeviceType() string { + switch runtime.GOOS { + case "darwin": + return "macos" + case "windows": + return "windows" + case "linux": + return "linux" + default: + return "browser" + } +} + +// ResolveDeviceID returns the persisted stable device_id, minting and persisting a fresh +// one ("dev_" + base64url(16 random bytes)) on first use. The format matches the backend's +// newDeviceID, so it passes validDeviceID and the broker's meta validation. +func ResolveDeviceID() (string, error) { + cfg, _ := LoadConfig() + if cfg.DeviceID != "" { + return cfg.DeviceID, nil + } + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + cfg.DeviceID = "dev_" + base64.RawURLEncoding.EncodeToString(b) + if err := SaveConfig(cfg); err != nil { + return "", err + } + return cfg.DeviceID, nil } // ResolveDeviceName returns the persisted device name, or the hostname when the diff --git a/desktop/platform/config.go b/desktop/platform/config.go index d4acccb..97db96a 100644 --- a/desktop/platform/config.go +++ b/desktop/platform/config.go @@ -10,11 +10,11 @@ import ( "time" ) -// FetchOAuthConfig pulls the provider coordinates from the cdrop backend's -// /api/auth/config. The desktop reuses the same OAuth client as the web app -// (the client_id published there) and runs its own loopback PKCE flow against -// the provider, instead of the browser's in-page redirect + /api/auth/exchange -// proxy. apiBase is the backend origin, e.g. https://drop.commilitia.net. +// FetchOAuthConfig pulls the Auth Broker coordinates from the cdrop backend's +// /api/auth/config (broker_url = the broker's public origin). The desktop runs the +// broker device-authorization flow (RFC 8252 loopback PKCE) against that broker, +// minting a cdrop-scoped machine token. apiBase is the backend origin, e.g. +// https://drop.commilitia.net. func FetchOAuthConfig(ctx context.Context, apiBase string) (OAuthConfig, error) { endpoint := strings.TrimRight(apiBase, "/") + "/api/auth/config" req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) @@ -34,23 +34,20 @@ func FetchOAuthConfig(ctx context.Context, apiBase string) (OAuthConfig, error) } var c struct { - AuthorizeURL string `json:"authorize_url"` - TokenURL string `json:"token_url"` - ClientID string `json:"client_id"` - Scopes string `json:"scopes"` + BrokerURL string `json:"broker_url"` + App string `json:"broker_app"` } if err := json.NewDecoder(resp.Body).Decode(&c); err != nil { return OAuthConfig{}, fmt.Errorf("oauth: decode config: %w", err) } - cfg := OAuthConfig{ - AuthorizeURL: c.AuthorizeURL, - TokenURL: c.TokenURL, - ClientID: c.ClientID, - Scopes: c.Scopes, + app := c.App + if app == "" { + app = "cdrop" } - if cfg.AuthorizeURL == "" || cfg.TokenURL == "" || cfg.ClientID == "" { - return OAuthConfig{}, errors.New("oauth: backend config missing authorize_url / token_url / client_id") + cfg := OAuthConfig{BrokerURL: c.BrokerURL, App: app} + if cfg.BrokerURL == "" { + return OAuthConfig{}, errors.New("oauth: backend config missing broker_url") } return cfg, nil } diff --git a/desktop/platform/config_test.go b/desktop/platform/config_test.go index bf6d92e..28ec169 100644 --- a/desktop/platform/config_test.go +++ b/desktop/platform/config_test.go @@ -15,12 +15,9 @@ func TestFetchOAuthConfig_Success(t *testing.T) { } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "auth_mode": "prod", - "authorize_url": "https://casdoor.example/login/oauth/authorize", - "token_url": "https://casdoor.example/api/login/oauth/access_token", - "client_id": "web-client-id", - "redirect_uri": "https://drop.example/oauth/callback", - "scopes": "openid profile groups", + "auth_mode": "prod", + "broker_url": "https://sso.example.net", + "broker_app": "cdrop", }) })) defer srv.Close() @@ -30,30 +27,39 @@ func TestFetchOAuthConfig_Success(t *testing.T) { if err != nil { t.Fatalf("FetchOAuthConfig: %v", err) } - if cfg.ClientID != "web-client-id" { - t.Errorf("client_id = %q, want web-client-id (reused web client)", cfg.ClientID) + if cfg.BrokerURL != "https://sso.example.net" { + t.Errorf("broker_url = %q", cfg.BrokerURL) } - if cfg.TokenURL != "https://casdoor.example/api/login/oauth/access_token" { - t.Errorf("token_url = %q", cfg.TokenURL) + if cfg.App != "cdrop" { + t.Errorf("app = %q, want cdrop", cfg.App) } - if cfg.Scopes != "openid profile groups" { - t.Errorf("scopes = %q", cfg.Scopes) +} + +func TestFetchOAuthConfig_DefaultsApp(t *testing.T) { + // broker_app omitted → defaults to "cdrop". + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"broker_url": "https://sso.example.net"}) + })) + defer srv.Close() + cfg, err := FetchOAuthConfig(context.Background(), srv.URL) + if err != nil { + t.Fatalf("FetchOAuthConfig: %v", err) + } + if cfg.App != "cdrop" { + t.Errorf("app = %q, want default cdrop", cfg.App) } } func TestFetchOAuthConfig_Incomplete(t *testing.T) { - // backend missing token_url / client_id (e.g. dev mode) must be rejected. + // backend missing broker_url (native login not configured) must be rejected. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "auth_mode": "dev", - "authorize_url": "https://casdoor.example/login/oauth/authorize", - }) + _ = json.NewEncoder(w).Encode(map[string]any{"auth_mode": "prod"}) })) defer srv.Close() - if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil { - t.Fatal("want error for incomplete backend config, got nil") + t.Fatal("want error for missing broker_url, got nil") } } @@ -62,7 +68,6 @@ func TestFetchOAuthConfig_BadStatus(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() - if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil { t.Fatal("want error for 500 status, got nil") } diff --git a/desktop/platform/devicesession.go b/desktop/platform/devicesession.go new file mode 100644 index 0000000..fbd53a3 --- /dev/null +++ b/desktop/platform/devicesession.go @@ -0,0 +1,89 @@ +package platform + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +// 代铸 (proxy-mint) on the desktop. The device-authorization flow yields a bootstrap +// machine token (scope app:cdrop, no meta) that proves the user's identity but is not a +// cdrop-managed device. This call exchanges it for a managed device session bound to this +// device's stable device_id, so the desktop joins cdrop's unified device list and is +// managed exactly like a browser — the same model, not a separate native-only track. + +// DeviceSessionResult is the managed device session minted by 代铸: the device tokens that +// supersede the bootstrap token, plus the verified identity (so a native client, which only +// ever sees nameless machine tokens, shows the real display name instead of the sub UUID). +type DeviceSessionResult struct { + AccessToken string + RefreshToken string + ExpiresIn int + DeviceID string + UserID string + Name string + Avatar string +} + +// MintDeviceSession POSTs to {apiBase}/api/auth/device-session with the bootstrap token as +// Bearer; cdrop vouches for the verified subject and has the broker mint (R2-idempotent by +// user+app+meta) a session bound to deviceID. Re-login with the same deviceID rotates the +// one session rather than piling up duplicates. +func MintDeviceSession(ctx context.Context, apiBase, bootstrapToken, deviceID, deviceName, deviceType string) (*DeviceSessionResult, error) { + body, err := json.Marshal(map[string]string{ + "device_id": deviceID, + "device_name": deviceName, + "device_type": deviceType, + }) + if err != nil { + return nil, fmt.Errorf("device-session: marshal: %w", err) + } + endpoint := strings.TrimRight(apiBase, "/") + "/api/auth/device-session" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("device-session: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+bootstrapToken) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("device-session: request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("device-session: status %d", resp.StatusCode) + } + + var dr struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + DeviceID string `json:"device_id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + } + if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil { + return nil, fmt.Errorf("device-session: decode: %w", err) + } + if dr.AccessToken == "" { + return nil, errors.New("device-session: response missing access token") + } + return &DeviceSessionResult{ + AccessToken: dr.AccessToken, + RefreshToken: dr.RefreshToken, + ExpiresIn: dr.ExpiresIn, + DeviceID: dr.DeviceID, + UserID: dr.UserID, + Name: dr.Name, + Avatar: dr.Avatar, + }, nil +} diff --git a/desktop/platform/devicesession_test.go b/desktop/platform/devicesession_test.go new file mode 100644 index 0000000..14763fc --- /dev/null +++ b/desktop/platform/devicesession_test.go @@ -0,0 +1,81 @@ +package platform + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// MintDeviceSession sends the bootstrap token as Bearer + the device descriptor, and maps +// the response into the device-session result the desktop then rides. +func TestMintDeviceSession(t *testing.T) { + var gotAuth string + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/auth/device-session" { + http.NotFound(w, r) + return + } + gotAuth = r.Header.Get("Authorization") + _ = json.NewDecoder(r.Body).Decode(&gotBody) + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "acc-x", "refresh_token": "rtk-x", "expires_in": 900, + "device_id": "dev_abc", "user_id": "sub-1", "name": "Commilitia", + "avatar": "https://example.net/a.png", + }) + })) + defer srv.Close() + + res, err := MintDeviceSession(context.Background(), srv.URL, "boot-token", "dev_abc", "Mac", "macos") + if err != nil { + t.Fatalf("MintDeviceSession: %v", err) + } + if gotAuth != "Bearer boot-token" { + t.Errorf("Authorization: got %q, want Bearer boot-token", gotAuth) + } + if gotBody["device_id"] != "dev_abc" || gotBody["device_name"] != "Mac" || gotBody["device_type"] != "macos" { + t.Errorf("request body: %+v", gotBody) + } + if res.AccessToken != "acc-x" || res.RefreshToken != "rtk-x" || res.DeviceID != "dev_abc" || + res.UserID != "sub-1" || res.Name != "Commilitia" || res.ExpiresIn != 900 || + res.Avatar != "https://example.net/a.png" { + t.Errorf("result: %+v", res) + } +} + +// A non-200 from the endpoint surfaces as an error rather than a half-built session. +func TestMintDeviceSession_ErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer srv.Close() + if _, err := MintDeviceSession(context.Background(), srv.URL, "boot", "dev_abc", "Mac", "macos"); err == nil { + t.Fatal("expected error on non-200, got nil") + } +} + +// ResolveDeviceID mints a validMeta-safe "dev_"-prefixed id and persists it (stable across +// calls). Uses an isolated config dir so it doesn't touch the real one. +func TestResolveDeviceID(t *testing.T) { + // os.UserConfigDir honours XDG_CONFIG_HOME on Linux and HOME on macOS. + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("HOME", dir) + id, err := ResolveDeviceID() + if err != nil { + t.Fatalf("ResolveDeviceID: %v", err) + } + if !strings.HasPrefix(id, "dev_") || len(id) < 8 { + t.Errorf("device id format: %q", id) + } + again, err := ResolveDeviceID() + if err != nil { + t.Fatalf("ResolveDeviceID (2): %v", err) + } + if again != id { + t.Errorf("device id not stable: %q != %q", again, id) + } +} diff --git a/desktop/platform/heal.go b/desktop/platform/heal.go new file mode 100644 index 0000000..318e631 --- /dev/null +++ b/desktop/platform/heal.go @@ -0,0 +1,104 @@ +package platform + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" +) + +// HealIdentity refreshes a persisted session's display identity (name + avatar) from the +// authoritative /api/me and re-persists it, BEFORE the session is injected into the WebView. +// +// Why: a session minted by an older build derived identity from the broker's nameless machine +// token, baking the subject UUID (and no avatar) into the on-disk session. The newer login path +// records the real name/picture, but an already-logged-in user keeps the stale session. Correcting +// it only in the WebView (per launch, via /api/me) is fragile — when the boot access token has +// expired and the refresh is momentarily flaky, the correction silently fails and the UUID shows +// again (the reported intermittent regression). Healing the persisted session here fixes it at the +// source: once a single launch succeeds, the on-disk identity is correct and every later boot is +// correct without any /api/me dependency. +// +// Synchronous and bounded so it can run before wails.Run; best-effort — on any network failure it +// returns the session unchanged (offline simply keeps whatever was last persisted, which a prior +// successful heal already corrected). If the access token has expired it refreshes once (and the +// rotated refresh token is persisted so it is not lost). +func HealIdentity(s *LoginResult, apiBase string) *LoginResult { + if s == nil || s.AccessToken == "" { + return s + } + // Once healed, the persisted name is a real display name (not the subject UUID); skip the + // /api/me round-trip then so it costs nothing on every later launch. A stale session (name + // still equal to the UUID) falls through and heals — fetching the avatar in the same pass. + if s.User.Name != "" && s.User.Name != s.User.ID { + return s + } + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + info, status := fetchMe(ctx, apiBase, s.AccessToken) + + tokenRotated := false + if status == http.StatusUnauthorized && s.RefreshToken != "" { + if cfg, err := FetchOAuthConfig(ctx, apiBase); err == nil { + if tok, rerr := NewFlow(cfg, func(string) {}).Refresh(ctx, s.RefreshToken); rerr == nil && tok.AccessToken != "" { + s.AccessToken = tok.AccessToken + if tok.RefreshToken != "" { + s.RefreshToken = tok.RefreshToken // broker rotates refresh; the old one is now dead + } + s.ExpiresIn = tok.ExpiresIn + tokenRotated = true + info, status = fetchMe(ctx, apiBase, s.AccessToken) + } + } + } + + changed := tokenRotated // a rotated refresh token must be persisted, regardless of the heal outcome + if status == http.StatusOK && info.ID != "" { + // Replace a stale name only with a real display name (not the subject UUID). + if info.Name != "" && info.Name != info.ID && info.Name != s.User.Name { + s.User.Name = info.Name + changed = true + } + if info.Avatar != "" && info.Avatar != s.User.Avatar { + s.User.Avatar = info.Avatar + changed = true + } + } + if changed { + if err := SaveSession(*s); err != nil { + slog.Warn("cdrop: heal identity save failed", "err", err) + } + } + return s +} + +// fetchMe GETs /api/me with the access token, returning the identity and HTTP status (0 on a +// transport error). It hits the public origin directly (no WebView proxy needed here). +func fetchMe(ctx context.Context, apiBase, accessToken string) (UserInfo, int) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(apiBase, "/")+"/api/me", nil) + if err != nil { + return UserInfo{}, 0 + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return UserInfo{}, 0 + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return UserInfo{}, resp.StatusCode + } + var m struct { + UserID string `json:"user_id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + } + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + return UserInfo{}, 0 + } + return UserInfo{ID: m.UserID, Name: m.Name, Avatar: m.Avatar}, http.StatusOK +} diff --git a/desktop/platform/heal_test.go b/desktop/platform/heal_test.go new file mode 100644 index 0000000..5a82937 --- /dev/null +++ b/desktop/platform/heal_test.go @@ -0,0 +1,66 @@ +package platform + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + keyring "github.com/zalando/go-keyring" +) + +// A session whose name is still the subject UUID (minted by an older build) heals from +// /api/me — name + avatar corrected and re-persisted, so later launches are correct without +// any further /api/me. +func TestHealIdentity_StaleNameHealed(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("HOME", dir) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/me" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "user_id": "sub-1", "name": "Commilitia", "avatar": "https://a.net/x.png", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + s := &LoginResult{ + AccessToken: "acc", RefreshToken: "rtk", ExpiresIn: 900, + User: UserInfo{ID: "sub-1", Name: "sub-1"}, // stale: name == subject UUID + } + out := HealIdentity(s, srv.URL) + if out.User.Name != "Commilitia" { + t.Errorf("name: got %q, want Commilitia", out.User.Name) + } + if out.User.Avatar != "https://a.net/x.png" { + t.Errorf("avatar: got %q, want the /api/me avatar", out.User.Avatar) + } + loaded, err := LoadSession() + if err != nil || loaded == nil || loaded.User.Name != "Commilitia" || loaded.User.Avatar != "https://a.net/x.png" { + t.Errorf("heal not persisted: %+v (err %v)", loaded, err) + } +} + +// A session that already has a real display name is left untouched and never hits /api/me. +func TestHealIdentity_HealthyNameSkips(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + s := &LoginResult{AccessToken: "acc", User: UserInfo{ID: "sub-1", Name: "Commilitia"}} + out := HealIdentity(s, srv.URL) + if called { + t.Error("a healthy identity must skip the /api/me round-trip") + } + if out.User.Name != "Commilitia" { + t.Errorf("name changed unexpectedly: %q", out.User.Name) + } +} diff --git a/desktop/platform/oauth.go b/desktop/platform/oauth.go index 1d6b654..21dd1eb 100644 --- a/desktop/platform/oauth.go +++ b/desktop/platform/oauth.go @@ -1,10 +1,12 @@ // Package platform implements cdrop desktop's native capabilities — the Go // shell that the shared web UI drives over Wails bindings. // -// This file owns the OAuth login flow: an RFC 8252 loopback redirect against -// Casdoor with PKCE as a public client (no secret). The authorization code -// exchange happens here in Go on purpose, so the PKCE verifier and the -// resulting tokens never enter the WebView / JS context. +// This file owns the login flow: the Auth Broker device-authorization flow (RFC +// 8252 loopback redirect with PKCE, as a public client — no secret). The desktop +// opens the system browser at the broker's consent page; on approval the broker +// redirects to the loopback /callback with a one-time code, which Go exchanges +// (with the PKCE verifier) for a broker machine token. The verifier and the +// resulting tokens never enter the WebView / JS context — refresh is Go-driven. package platform import ( @@ -22,29 +24,25 @@ import ( "time" ) -// OAuthConfig carries the provider coordinates. For Casdoor the token endpoint -// is /api/login/oauth/access_token (non-standard) and authorize is -// /login/oauth/authorize; the desktop registers as its own public client. +// OAuthConfig carries the Auth Broker coordinates. BrokerURL is the broker's PUBLIC +// origin (e.g. https://sso.commilitia.net); App is this app's key in the broker apps +// registry ("cdrop"). The app's loopback redirect must be registered in the broker's +// apps.json (redirect_uris) for the device flow to accept it. type OAuthConfig struct { - AuthorizeURL string - TokenURL string - ClientID string - Scopes string // space-separated; empty defaults to "openid profile groups" + BrokerURL string + App string } -// TokenResult is the subset of the token response the shell keeps. IDToken is -// captured so the identity can be decoded from the richer id_token claims (it's -// present when the openid scope is granted — our default scopes include it). +// TokenResult is the subset of the broker token response the shell keeps. ExpiresIn +// is derived from the broker's access_expires (unix) at parse time. type TokenResult 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"` + AccessToken string + RefreshToken string + ExpiresIn int } -// Flow runs one interactive login. openURL is injected so production uses -// Wails' runtime.BrowserOpenURL while tests substitute a fake user-agent. +// Flow runs one interactive login. openURL is injected so production uses Wails' +// runtime.BrowserOpenURL while tests substitute a fake user-agent. type Flow struct { cfg OAuthConfig openURL func(string) @@ -63,15 +61,17 @@ func NewFlow(cfg OAuthConfig, openURL func(string)) *Flow { } } -// Login performs the loopback PKCE flow and returns the exchanged tokens. +// Login performs the broker device-authorization loopback PKCE flow and returns the +// minted machine token. // -// Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1 -// listener → open the system browser at the authorize URL → wait for the -// browser to hit the loopback /callback with the code → validate state → -// exchange code+verifier at the token endpoint. The verifier never leaves Go. +// Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1 listener → +// open the system browser at the broker's /device/authorize (which sends the user +// through broker SSO if needed, then shows a consent page) → wait for the browser to +// hit the loopback /callback with the code → validate state → exchange code+verifier +// at /device/token. The verifier never leaves Go. func (f *Flow) Login(ctx context.Context) (*TokenResult, error) { - if f.cfg.AuthorizeURL == "" || f.cfg.TokenURL == "" || f.cfg.ClientID == "" { - return nil, errors.New("oauth: incomplete config (authorize_url / token_url / client_id)") + if f.cfg.BrokerURL == "" || f.cfg.App == "" { + return nil, errors.New("oauth: incomplete config (broker_url / app)") } verifier, challenge, err := newPKCE() @@ -132,14 +132,13 @@ func (f *Flow) Login(ctx context.Context) (*TokenResult, error) { go func() { _ = srv.Serve(ln) }() defer srv.Close() - authURL := f.cfg.AuthorizeURL + "?" + url.Values{ - "response_type": {"code"}, - "client_id": {f.cfg.ClientID}, + authURL := strings.TrimRight(f.cfg.BrokerURL, "/") + "/device/authorize?" + url.Values{ + "app": {f.cfg.App}, "redirect_uri": {redirectURI}, - "scope": {f.scopes()}, "state": {state}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}, + "description": {"cdrop 桌面客户端"}, }.Encode() f.openURL(authURL) @@ -156,52 +155,50 @@ func (f *Flow) Login(ctx context.Context) (*TokenResult, error) { } } -func (f *Flow) scopes() string { - if s := strings.TrimSpace(f.cfg.Scopes); s != "" { - return s - } - return "openid profile groups" -} - -// exchange trades the authorization code + PKCE verifier for tokens. Casdoor -// accepts form-encoded public-client requests (no client_secret). authorize and -// token must carry the identical redirect_uri, so we pass the same value. +// exchange trades the authorization code + PKCE verifier (+ the same redirect_uri) +// for the broker machine token at /device/token. func (f *Flow) exchange(ctx context.Context, code, verifier, redirectURI string) (*TokenResult, error) { - return f.postToken(ctx, url.Values{ - "grant_type": {"authorization_code"}, - "code": {code}, - "code_verifier": {verifier}, - "client_id": {f.cfg.ClientID}, - "redirect_uri": {redirectURI}, + return f.postJSON(ctx, strings.TrimRight(f.cfg.BrokerURL, "/")+"/device/token", map[string]string{ + "code": code, + "code_verifier": verifier, + "redirect_uri": redirectURI, }) } -// Refresh exchanges a refresh_token for a fresh token (public client, no -// secret). Call it when the access token nears expiry; the access token never -// leaves Go, so refresh is driven from the shell, not the WebView. +// Refresh exchanges a refresh credential for a fresh access token (+ rotated refresh) +// at the broker's /refresh. Call it when the access token nears expiry; the refresh +// credential never leaves Go, so refresh is driven from the shell, not the WebView. func (f *Flow) Refresh(ctx context.Context, refreshToken string) (*TokenResult, error) { - if f.cfg.TokenURL == "" || f.cfg.ClientID == "" { - return nil, errors.New("oauth: incomplete config (token_url / client_id)") + if f.cfg.BrokerURL == "" { + return nil, errors.New("oauth: incomplete config (broker_url)") } if refreshToken == "" { return nil, errors.New("oauth: empty refresh token") } - return f.postToken(ctx, url.Values{ - "grant_type": {"refresh_token"}, - "refresh_token": {refreshToken}, - "client_id": {f.cfg.ClientID}, - "scope": {f.scopes()}, + return f.postJSON(ctx, strings.TrimRight(f.cfg.BrokerURL, "/")+"/refresh", map[string]string{ + "refresh": refreshToken, }) } -// postToken POSTs a form-encoded request to the token endpoint and decodes the -// response. Shared by the authorization-code exchange and refresh. -func (f *Flow) postToken(ctx context.Context, form url.Values) (*TokenResult, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.TokenURL, strings.NewReader(form.Encode())) +// brokerTokenResp is the broker's response shape for /device/token and /refresh. +type brokerTokenResp struct { + Access string `json:"access"` + Refresh string `json:"refresh"` + AccessExpires int64 `json:"access_expires"` +} + +// postJSON POSTs a JSON body to a broker token endpoint and decodes the response. +// Shared by the device-token exchange and refresh. +func (f *Flow) postJSON(ctx context.Context, endpoint string, body map[string]string) (*TokenResult, error) { + buf, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("oauth: marshal token request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(buf))) if err != nil { return nil, fmt.Errorf("oauth: build token request: %w", err) } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := f.client.Do(req) @@ -212,14 +209,18 @@ func (f *Flow) postToken(ctx context.Context, form url.Values) (*TokenResult, er if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("oauth: token endpoint status %d", resp.StatusCode) } - var tok TokenResult - if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { + var tr brokerTokenResp + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { return nil, fmt.Errorf("oauth: decode token: %w", err) } - if tok.AccessToken == "" { - return nil, errors.New("oauth: token response missing access_token") + if tr.Access == "" { + return nil, errors.New("oauth: token response missing access") } - return &tok, nil + expiresIn := int(tr.AccessExpires - time.Now().Unix()) + if expiresIn < 0 { + expiresIn = 0 + } + return &TokenResult{AccessToken: tr.Access, RefreshToken: tr.Refresh, ExpiresIn: expiresIn}, nil } // --- PKCE (RFC 7636) --- diff --git a/desktop/platform/oauth_test.go b/desktop/platform/oauth_test.go index f3f15cd..888663f 100644 --- a/desktop/platform/oauth_test.go +++ b/desktop/platform/oauth_test.go @@ -10,6 +10,7 @@ import ( "net/url" "strings" "testing" + "time" ) func TestNewPKCE(t *testing.T) { @@ -32,45 +33,55 @@ func TestNewPKCE(t *testing.T) { } } -// TestLogin_Success drives the whole loopback flow with a fake Casdoor token -// endpoint and a fake browser, with no real network or prod dependency. +// decodeBody reads a JSON request body into a map. +func decodeBody(t *testing.T, r *http.Request) map[string]string { + t.Helper() + var m map[string]string + if err := json.NewDecoder(r.Body).Decode(&m); err != nil { + t.Fatalf("decode body: %v", err) + } + return m +} + +// TestLogin_Success drives the whole broker device-authorization loopback flow with a +// fake /device/token endpoint and a fake browser — no real network or prod dependency. func TestLogin_Success(t *testing.T) { - var gotForm url.Values - tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - t.Errorf("token endpoint ParseForm: %v", err) + var gotBody map[string]string + brokerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/device/token" { + t.Errorf("token path = %s, want /device/token", r.URL.Path) } - gotForm = r.PostForm + gotBody = decodeBody(t, r) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "access_token": "at-123", - "refresh_token": "rt-456", - "expires_in": 3600, - "token_type": "Bearer", + "id": "sid-1", + "app": "cdrop", + "access": "at-123", + "refresh": "rtk-456", + "access_expires": time.Now().Add(15 * time.Minute).Unix(), }) })) - defer tokenSrv.Close() + defer brokerSrv.Close() - cfg := OAuthConfig{ - AuthorizeURL: "https://casdoor.example/login/oauth/authorize", - TokenURL: tokenSrv.URL, - ClientID: "cdrop-desktop", - } + cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "cdrop"} - // Fake browser: parse the authorize URL, assert it carries PKCE, then GET - // the loopback redirect with a code + the same state (authorized). + // Fake browser: parse the /device/authorize URL, assert it carries app + PKCE, + // then GET the loopback redirect with a code + the same state (approved). openURL := func(authURL string) { u, err := url.Parse(authURL) if err != nil { t.Errorf("parse authURL: %v", err) return } + if !strings.HasSuffix(u.Path, "/device/authorize") { + t.Errorf("authorize path = %s, want /device/authorize", u.Path) + } q := u.Query() if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" { t.Errorf("authorize request missing PKCE challenge: %v", q) } - if q.Get("client_id") != "cdrop-desktop" { - t.Errorf("authorize client_id = %q", q.Get("client_id")) + if q.Get("app") != "cdrop" { + t.Errorf("authorize app = %q", q.Get("app")) } cb := q.Get("redirect_uri") + "?code=auth-code-xyz&state=" + url.QueryEscape(q.Get("state")) resp, err := http.Get(cb) @@ -86,37 +97,29 @@ func TestLogin_Success(t *testing.T) { t.Fatalf("Login: %v", err) } if tok.AccessToken != "at-123" { - t.Errorf("access_token = %q, want at-123", tok.AccessToken) + t.Errorf("access = %q, want at-123", tok.AccessToken) } - if tok.RefreshToken != "rt-456" { - t.Errorf("refresh_token = %q, want rt-456", tok.RefreshToken) + if tok.RefreshToken != "rtk-456" { + t.Errorf("refresh = %q, want rtk-456", tok.RefreshToken) } - if tok.ExpiresIn != 3600 { - t.Errorf("expires_in = %d, want 3600", tok.ExpiresIn) + if tok.ExpiresIn <= 0 || tok.ExpiresIn > 900 { + t.Errorf("expires_in = %d, want ~900 (derived from access_expires)", tok.ExpiresIn) } - // The exchange must carry the code + PKCE verifier and, as a public client, - // no client_secret. - if gotForm.Get("grant_type") != "authorization_code" { - t.Errorf("grant_type = %q", gotForm.Get("grant_type")) + // The exchange must carry the code, the PKCE verifier, and the same redirect_uri. + if gotBody["code"] != "auth-code-xyz" { + t.Errorf("code = %q", gotBody["code"]) } - if gotForm.Get("code") != "auth-code-xyz" { - t.Errorf("code = %q", gotForm.Get("code")) - } - if gotForm.Get("code_verifier") == "" { + if gotBody["code_verifier"] == "" { t.Error("token exchange missing code_verifier") } - if gotForm.Get("client_secret") != "" { - t.Error("public client must not send client_secret") + if !strings.HasPrefix(gotBody["redirect_uri"], "http://127.0.0.1:") { + t.Errorf("redirect_uri = %q, want loopback", gotBody["redirect_uri"]) } } func TestLogin_StateMismatch(t *testing.T) { - cfg := OAuthConfig{ - AuthorizeURL: "https://casdoor.example/login/oauth/authorize", - TokenURL: "https://casdoor.example/token", - ClientID: "cdrop-desktop", - } + cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "cdrop"} openURL := func(authURL string) { u, _ := url.Parse(authURL) redirect := u.Query().Get("redirect_uri") @@ -132,55 +135,46 @@ func TestLogin_StateMismatch(t *testing.T) { } func TestLogin_IncompleteConfig(t *testing.T) { - _, err := NewFlow(OAuthConfig{ClientID: "only-id"}, func(string) {}).Login(context.Background()) + _, err := NewFlow(OAuthConfig{App: "only-app"}, func(string) {}).Login(context.Background()) if err == nil { t.Fatal("want error for incomplete config, got nil") } } func TestRefresh_Success(t *testing.T) { - var gotForm url.Values - tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - gotForm = r.PostForm + var gotBody map[string]string + brokerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/refresh" { + t.Errorf("refresh path = %s, want /refresh", r.URL.Path) + } + gotBody = decodeBody(t, r) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "access_token": "new-at", - "refresh_token": "new-rt", - "expires_in": 3600, + "access": "new-at", + "refresh": "new-rtk", + "access_expires": time.Now().Add(15 * time.Minute).Unix(), }) })) - defer tokenSrv.Close() + defer brokerSrv.Close() - cfg := OAuthConfig{ - AuthorizeURL: "https://casdoor.example/login/oauth/authorize", - TokenURL: tokenSrv.URL, - ClientID: "cdrop-desktop", - } - tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rt") + cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "cdrop"} + tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rtk") if err != nil { t.Fatalf("Refresh: %v", err) } if tok.AccessToken != "new-at" { - t.Errorf("access_token = %q, want new-at", tok.AccessToken) + t.Errorf("access = %q, want new-at", tok.AccessToken) } - if gotForm.Get("grant_type") != "refresh_token" { - t.Errorf("grant_type = %q", gotForm.Get("grant_type")) + if tok.RefreshToken != "new-rtk" { + t.Errorf("refresh = %q, want new-rtk (rotated)", tok.RefreshToken) } - if gotForm.Get("refresh_token") != "old-rt" { - t.Errorf("refresh_token = %q", gotForm.Get("refresh_token")) - } - if gotForm.Get("client_secret") != "" { - t.Error("public client must not send client_secret on refresh") + if gotBody["refresh"] != "old-rtk" { + t.Errorf("sent refresh = %q, want old-rtk", gotBody["refresh"]) } } func TestRefresh_EmptyToken(t *testing.T) { - cfg := OAuthConfig{ - AuthorizeURL: "https://x/authorize", - TokenURL: "https://x/token", - ClientID: "c", - } + cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "cdrop"} if _, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), ""); err == nil { t.Fatal("want error for empty refresh token") } diff --git a/docker/Caddyfile.snippet b/docker/Caddyfile.snippet index f772f0b..050a858 100644 --- a/docker/Caddyfile.snippet +++ b/docker/Caddyfile.snippet @@ -1,12 +1,64 @@ -# 并入你的主 Caddyfile(站点 block)。换上你的域名与服务名。 +# cdrop(drop.)的 Caddy 接线示例。换上你的域名、cdrop 服务名(NN-cdrop)、 +# broker 内网地址(BROKER-IP:PORT)。 +# +# cdrop 迁移到 Auth Broker(路径 A)后,边缘由 broker /verify 终结鉴权并注入 X-Auth-* 头: +# - 含 Authorization: Bearer 的请求 → 机器分支(QR 配对设备 / iOS / 桌面,持 broker 机器令牌) +# - 不含 Bearer 的请求 → 人类分支(全新浏览器全局 SSO,带 broker 域 cookie) +# - 一组公开端点必须放行不鉴权,否则未登录设备无从登录 / 刷新。 +# +# 前置:用 xcaddy 把 caddybroker 编进 caddy-custom,并在全局块声明 +# { order broker before reverse_proxy } +# 否则 `handle { broker ... }` 报 "not an ordered HTTP handler"。 +# # 关键开关: -# - flush_interval -1 :禁用 reverse_proxy 对 SSE 长响应的缓冲,让探测帧 -# 与 ping 立即送达浏览器;任何反代器忽略此项都会让 /api/hub/events 看似无事件 -# - h3 建议在 global block 启用(站点级不必重复声明) -# - NN-cdrop 中的 NN 与 compose 服务条目保持一致 +# - flush_interval -1 :禁用 reverse_proxy 对 SSE 长响应的缓冲,否则 /api/hub/events 看似无事件 +# - copy_headers 须含 X-Auth-Meta(cdrop 据它定位托管设备);caddybroker 默认已含, +# 但旧 caddy-custom 镜像须**重建**才生效——接 cdrop 那次必须连带重建。 -your-domain.example { - reverse_proxy NN-cdrop:8080 { - flush_interval -1 +drop.your-domain.example { + import tls_min + + # 公开端点(放行不鉴权):登录引导(302 跳 broker)/ 原生取 broker 坐标 / 令牌刷新代理 / + # 扫码公开端点(新设备未登录,凭 poll_secret 自证)。 + @public path /api/auth/login /api/auth/config /api/auth/refresh /api/auth/qr/start /api/auth/qr/status + handle @public { + reverse_proxy NN-cdrop:8080 { + flush_interval -1 + } + } + + # 受控 /api/* 机器分支:含 Bearer → broker /verify 验机器令牌 → 注入 X-Auth-*。 + @apiBearer { + path /api/* + header Authorization Bearer* + } + handle @apiBearer { + broker BROKER-IP:8080 { + app cdrop + copy_headers X-Auth-Subject X-Auth-Name X-Auth-Roles X-Auth-Scope X-Auth-Kind X-Auth-Meta + } + reverse_proxy NN-cdrop:8080 { + flush_interval -1 + } + } + + # 受控 /api/* 人类分支:无 Bearer → broker /verify 验全局 SSO cookie → 注入 X-Auth-*。 + @api path /api/* + handle @api { + broker BROKER-IP:8080 { + app cdrop + copy_headers X-Auth-Subject X-Auth-Name X-Auth-Roles X-Auth-Scope X-Auth-Kind X-Auth-Meta + } + reverse_proxy NN-cdrop:8080 { + flush_interval -1 + } + } + + # 静态 SPA 壳(其余路径):放行不鉴权,前端自行引导登录。 + handle { + encode gzip + reverse_proxy NN-cdrop:8080 { + flush_interval -1 + } } } diff --git a/docker/compose.snippet.yaml b/docker/compose.snippet.yaml index f89cc9c..71bfcea 100644 --- a/docker/compose.snippet.yaml +++ b/docker/compose.snippet.yaml @@ -21,33 +21,31 @@ NN-cdrop: CDROP_DEVICE_TTL_HOURS: "196" # ---- prod 切换时启用以下,并把 CDROP_AUTH_MODE 改成 prod、删掉 DEV_TOKEN ---- - # CDROP_OIDC_AUTHORIZE_URL: https://your-idp.example/login/oauth/authorize - # CDROP_OIDC_TOKEN_URL: https://your-idp.example/api/login/oauth/access_token - # JWKS 可走 IdP 内网地址省一跳;也可用公网 https。 - # CDROP_OIDC_JWKS_URL: https://your-idp.example/.well-known/jwks - # CDROP_OIDC_ISSUER: https://your-idp.example/ - # prod 强制非空(空则跳过 audience 校验 = 同 JWKS 其他应用可冒充) - # CDROP_OIDC_AUDIENCE: <你的 OAuth client_id> - # CDROP_OIDC_CLIENT_ID: <你的 OAuth client_id> - # CDROP_OIDC_REDIRECT_URI: https://your-domain.example/oauth/callback - # CDROP_OIDC_SCOPES: "openid profile email" - # prod 强制非空:浏览器免重登会话 refresh_token 落盘加密密钥(SHA-256 派生 AES-256) - # CDROP_SESSION_SECRET: REPLACE_WITH_RANDOM_HEX64 - # CDROP_HS256_SECRET: REPLACE_WITH_RANDOM_HEX64 + # cdrop 委托 Auth Broker(路径 A):身份与会话生命周期归 broker,cdrop 不存凭证。 + # broker 内网地址(docker 内网直连,绝不经公网反代)。prod 强制非空。 + # CDROP_BROKER_BASE_URL: http://broker-internal-ip:8080 + # broker /internal/* 共享密钥(X-Internal-Key)。prod 强制非空,向运维取。 + # CDROP_BROKER_INTERNAL_KEY: REPLACE_WITH_BROKER_INTERNAL_KEY + # broker 公开源(浏览器全局 SSO 登录跳转 / 原生设备授权流目标)。 + # CDROP_BROKER_PUBLIC_URL: https://sso.your-domain.example + # 本应用在 broker apps 注册表里的 key(默认 cdrop)。 + # CDROP_BROKER_APP: cdrop + # 部署公开源(扫码 QR 链接 / CSRF Origin / Web Push 默认联系标识)。 + # CDROP_PUBLIC_URL: https://drop.your-domain.example # ---- 可选:Web Push(VAPID)推送通知(不配则推送惰性关闭)---- # 用 `just vapid-keygen` 生成一对,二者须同时设置(半对即拒启动) # CDROP_VAPID_PUBLIC_KEY: # CDROP_VAPID_PRIVATE_KEY: - # ---- 可选:扫码登录(QR)与 cdrop 自签会话(SESSION_SECRET 已设时默认开启)---- + # ---- 可选:扫码登录(QR)+ 按档令牌 TTL(批准后经 broker 委托签发对应 tier)---- # 均有默认值;下列按需覆盖。关 QR_LOGIN_ENABLED 则 /api/auth/qr/* 返回 503。 - # CDROP_QR_LOGIN_ENABLED: "true" - # CDROP_QR_REQUEST_TTL_SECONDS: "120" # 二维码 / 登录请求有效期 - # CDROP_QR_GUEST_TTL_SECONDS: "3600" # 仅此次(受限访客)会话 TTL - # CDROP_QR_PERSIST_TTL_HOURS: "168" # 信任此设备(持久)会话 TTL - # CDROP_SESSION_TOKEN_TTL_SECONDS: "900" # cdrop 自签 access token TTL - # CDROP_ACCOUNT_MATCH_CLAIM: "email" # 写入 accounts.match_key 的 claim + # CDROP_QR_LOGIN_ENABLED: "true" + # CDROP_QR_REQUEST_TTL_SECONDS: "120" # 二维码 / 登录请求有效期 + # CDROP_FULL_ACCESS_TTL_SECONDS: "900" # full 设备 access TTL + # CDROP_FULL_REFRESH_TTL_SECONDS: "604800" # full 设备 refresh TTL(7 天) + # CDROP_GUEST_ACCESS_TTL_SECONDS: "900" # guest 访客 access TTL + # CDROP_GUEST_REFRESH_TTL_SECONDS: "86400" # guest refresh TTL(1 天) # ---- 可选:Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底)---- # CDROP_CF_TURN_KEY_ID: diff --git a/go.mod b/go.mod index 5222c00..db0a78f 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/SherClockHolmes/webpush-go v1.4.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/httprate v0.15.0 - github.com/go-jose/go-jose/v4 v4.1.4 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env/v2 v2.0.0 github.com/knadh/koanf/providers/file v1.2.1 diff --git a/go.sum b/go.sum index 2573d5e..f81aa33 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,6 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= diff --git a/internal/brokerclient/client.go b/internal/brokerclient/client.go new file mode 100644 index 0000000..e3a8018 --- /dev/null +++ b/internal/brokerclient/client.go @@ -0,0 +1,243 @@ +// Package brokerclient is cdrop's thin client for the Auth Broker's HTTP API +// (path A). cdrop delegates session minting, revocation, and refresh to the broker +// rather than self-signing; this package wraps the three calls cdrop makes. +// +// Every call goes DIRECTLY to the broker's internal network address (e.g. +// http://broker-internal-ip:8080), never through the public reverse proxy — the broker's +// /internal/* endpoints 404 any request carrying X-Forwarded-For, which a request +// relayed through the edge always has. The Go HTTP client never sets that header, so +// a direct dial passes the guard; the shared X-Internal-Key is the second factor. +package brokerclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// ErrNotFound is returned for a broker 404. Revocation maps it to success +// (idempotent: an unknown / already-gone / not-ours sid needs no action). +var ErrNotFound = errors.New("brokerclient: not found") + +// Client talks to one Auth Broker instance on behalf of one app. +type Client struct { + baseURL string + internalKey string + app string + httpClient *http.Client +} + +// New builds a Client. baseURL is the broker's internal origin; internalKey is the +// shared secret for /internal/*; app is this app's key in the broker apps registry. +func New(baseURL, internalKey, app string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + internalKey: internalKey, + app: app, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// MintParams is one delegated-session request. The app vouches for UserID (the +// subject the broker just verified for the approver). Tier is appended to the app +// scope (app::); Meta is opaque correlation data (cdrop's device_id) the +// broker echoes back on /verify as X-Auth-Meta. +type MintParams struct { + UserID string + Tier string + AccessTTL int + RefreshTTL int + Sliding bool + Label string + Meta string +} + +// Session is a freshly minted delegated session. SID is the broker's session id — +// cdrop stores it privately to revoke later; it never travels in a /verify header. +type Session struct { + SID string + Access string + Refresh string + AccessExpires int64 + RefreshExpires int64 +} + +type mintReqWire struct { + UserID string `json:"user_id"` + App string `json:"app"` + Tier string `json:"tier,omitempty"` + AccessTTL int `json:"access_ttl,omitempty"` + RefreshTTL int `json:"refresh_ttl,omitempty"` + Sliding bool `json:"sliding,omitempty"` + Label string `json:"label,omitempty"` + Meta string `json:"meta,omitempty"` +} + +type sessionWire struct { + ID string `json:"id"` + Access string `json:"access"` + Refresh string `json:"refresh"` + AccessExpires int64 `json:"access_expires"` + RefreshExpires int64 `json:"refresh_expires"` +} + +// MintSession asks the broker to mint a session for the vouched user (POST +// /internal/sessions). The plaintext access + refresh come back exactly once. +func (c *Client) MintSession(ctx context.Context, p MintParams) (Session, error) { + body := mintReqWire{ + UserID: p.UserID, App: c.app, Tier: p.Tier, + AccessTTL: p.AccessTTL, RefreshTTL: p.RefreshTTL, Sliding: p.Sliding, + Label: p.Label, Meta: p.Meta, + } + headers := map[string]string{"X-Internal-Key": c.internalKey} + var out sessionWire + if err := c.do(ctx, http.MethodPost, "/internal/sessions", headers, body, http.StatusOK, &out); err != nil { + return Session{}, err + } + return Session{ + SID: out.ID, Access: out.Access, Refresh: out.Refresh, + AccessExpires: out.AccessExpires, RefreshExpires: out.RefreshExpires, + }, nil +} + +// RevokeSession revokes a delegated session by sid (DELETE /internal/sessions/{sid}). +// X-Broker-App scopes the revoke to this app: the broker confirms the session belongs +// to cdrop before tearing it down (a mismatch 404s). A 404 is treated as success — +// revocation is idempotent. +func (c *Client) RevokeSession(ctx context.Context, sid string) error { + headers := map[string]string{ + "X-Internal-Key": c.internalKey, + "X-Broker-App": c.app, + } + err := c.do(ctx, http.MethodDelete, "/internal/sessions/"+url.PathEscape(sid), headers, nil, http.StatusNoContent, nil) + if errors.Is(err, ErrNotFound) { + return nil + } + return err +} + +// Refreshed is the result of rolling a session's access token. The broker rotates +// the refresh credential, so the old one is now dead and the new one must be stored. +type Refreshed struct { + Access string + Refresh string + AccessExpires int64 + RefreshExpires int64 +} + +// RefreshSession exchanges a refresh credential for a fresh access token and a +// rotated refresh credential (POST /refresh — a public broker endpoint, no internal +// key). ErrNotFound is not used here; an invalid/expired/rotated credential is a 401. +func (c *Client) RefreshSession(ctx context.Context, refresh string) (Refreshed, error) { + body := map[string]string{"refresh": refresh} + var out sessionWire + if err := c.do(ctx, http.MethodPost, "/refresh", nil, body, http.StatusOK, &out); err != nil { + return Refreshed{}, err + } + return Refreshed{ + Access: out.Access, Refresh: out.Refresh, + AccessExpires: out.AccessExpires, RefreshExpires: out.RefreshExpires, + }, nil +} + +// SessionInfo is one of the user's delegated device sessions, as returned by the broker's +// R1 listing (GET /internal/sessions?user_id=&app=). After the unified-session-model +// rework this IS the authoritative device list: cdrop overlays type (local cache), online +// (hub presence), and current (meta == this request's X-Auth-Meta) on top of it, and never +// keeps a parallel authoritative session table. SID is the broker session id — the revoke +// handle cdrop stores privately; it is never exposed to clients. Meta is cdrop's device_id +// (the session<->device join key). The broker returns only kind==machine sessions for this +// app and never includes credentials. +type SessionInfo struct { + SID string + Scope string + Label string + Meta string + CreatedAt int64 + LastUsedAt int64 + ExpiresAt int64 +} + +type sessionInfoWire struct { + ID string `json:"id"` + Scope string `json:"scope"` + Label string `json:"label"` + Meta string `json:"meta"` + CreatedAt int64 `json:"created_at"` + LastUsedAt int64 `json:"last_used_at"` + ExpiresAt int64 `json:"expires_at"` +} + +// ListSessions lists the user's delegated device sessions for this app (R1). It hits the +// same internal trust boundary as MintSession — X-Internal-Key over a direct internal dial, +// so the request carries no X-Forwarded-For and passes the broker's guard. cdrop vouches +// for userID (the subject the edge already verified). The broker returns only kind==machine +// sessions for app==c.app; cdrop treats the result as the authoritative device list. +func (c *Client) ListSessions(ctx context.Context, userID string) ([]SessionInfo, error) { + q := url.Values{"user_id": {userID}, "app": {c.app}} + headers := map[string]string{"X-Internal-Key": c.internalKey} + var out struct { + Sessions []sessionInfoWire `json:"sessions"` + } + if err := c.do(ctx, http.MethodGet, "/internal/sessions?"+q.Encode(), headers, nil, http.StatusOK, &out); err != nil { + return nil, err + } + sessions := make([]SessionInfo, 0, len(out.Sessions)) + for _, s := range out.Sessions { + sessions = append(sessions, SessionInfo{ + SID: s.ID, Scope: s.Scope, Label: s.Label, Meta: s.Meta, + CreatedAt: s.CreatedAt, LastUsedAt: s.LastUsedAt, ExpiresAt: s.ExpiresAt, + }) + } + return sessions, nil +} + +// do issues one request and decodes a successful response into out (when non-nil). +// A response whose status differs from wantStatus is an error; 404 maps to +// ErrNotFound so callers can special-case it. +func (c *Client) do(ctx context.Context, method, path string, headers map[string]string, body any, wantStatus int, out any) error { + var reader io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("brokerclient: marshal body: %w", err) + } + reader = bytes.NewReader(buf) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return fmt.Errorf("brokerclient: build request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("brokerclient: %s %s: %w", method, path, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + if resp.StatusCode != wantStatus { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("brokerclient: %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(snippet))) + } + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("brokerclient: decode response: %w", err) + } + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 29eda35..2a2140c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,35 +27,38 @@ type Config struct { // window at 196h (≈8 days) — devices must not outlive that. Default 196h. DeviceTTLHours int `koanf:"device_ttl_hours"` - // OIDC settings. Generic OAuth/OIDC provider compatible (Casdoor included). - OIDCAuthorizeURL string `koanf:"oidc_authorize_url"` - OIDCTokenURL string `koanf:"oidc_token_url"` - OIDCJWKSURL string `koanf:"oidc_jwks_url"` - OIDCIssuer string `koanf:"oidc_issuer"` - // OIDCAudience accepts a comma-separated list. A token passes when its `aud` - // matches any one entry — this lets one backend serve several OAuth clients - // that carry different audiences (e.g. the web app and the desktop client). - // Empty disables audience checking; a single value behaves as before. - OIDCAudience string `koanf:"oidc_audience"` - OIDCClientID string `koanf:"oidc_client_id"` - OIDCClientSecret string `koanf:"oidc_client_secret"` - OIDCRedirectURI string `koanf:"oidc_redirect_uri"` - OIDCScopes string `koanf:"oidc_scopes"` + // PublicURL 是部署的公开源(scheme://host),如 https://drop.commilitia.net。 + // 用于拼扫码登录的 QR 链接、作 Web Push 的默认联系标识。dev 留空时回退到请求自身 + // 的 Host。 + PublicURL string `koanf:"public_url"` - HS256Secret string `koanf:"hs256_secret"` + // Auth Broker 接入(路径 A)。cdrop 把身份与会话生命周期委托给 broker:每个受控 + // 请求在边缘由 broker /verify(经 Caddy)鉴权并注入 X-Auth-* 头,cdrop 直接信任; + // 扫码批准时经 broker 内部 API 委托签发会话,cdrop 不再自签。 + // BrokerBaseURL —— broker 内网地址(如 http://broker-internal-ip:8080),在 docker + // 内网直连(绝不经公网反代,以保 /internal/* 与 /refresh 可达)。 + // BrokerInternalKey —— broker /internal/* 端点的共享密钥(作 X-Internal-Key 发送), + // prod 必填。 + // BrokerApp —— 本应用在 broker apps 注册表里的 key(默认 "cdrop")。 + BrokerBaseURL string `koanf:"broker_base_url"` + BrokerInternalKey string `koanf:"broker_internal_key"` + BrokerApp string `koanf:"broker_app"` - // SessionSecret keys the AES-256-GCM encryption of browser refresh_tokens at - // rest in web_sessions (the "passwordless re-login" feature). Any-length - // high-entropy string; it's SHA-256'd into a 32-byte key. The key lives only - // here (container env), never in the DB file, so an exfiltrated SQLite file - // can't be decrypted. Required in prod — refuse to boot without it. - SessionSecret string `koanf:"session_secret"` + // BrokerPublicURL is the broker's PUBLIC origin (e.g. https://sso.commilitia.net) + // — where a browser is sent to log in via global SSO. Distinct from BrokerBaseURL + // (the internal address cdrop's server-to-server calls use). GET /api/auth/login + // 302-redirects here; empty disables that redirect (native-only deployments). + BrokerPublicURL string `koanf:"broker_public_url"` - // Shortcut tokens:iOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期 - // (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。 - // 需配 HS256Secret 才启用,否则签发端点返回 503。 - ShortcutTokenTTLDays int `koanf:"shortcut_token_ttl_days"` - ShortcutMaxPerUser int `koanf:"shortcut_max_per_user"` + // 按档令牌 TTL(秒):cdrop 经 broker 委托签发会话时请求的 access / refresh 寿命。 + // full=可信设备(access 短、refresh 一周、滑动);guest=扫码借用(同样的短 access、 + // refresh 一天)。两档都在 broker 的 app 上限内(access 3600 / refresh 604800), + // clampTTL 不会压它们。broker /verify 每请求查活,短 access 只决定续期频率、不影响 + // 吊销即时性。 + FullAccessTTLSeconds int `koanf:"full_access_ttl_seconds"` + FullRefreshTTLSeconds int `koanf:"full_refresh_ttl_seconds"` + GuestAccessTTLSeconds int `koanf:"guest_access_ttl_seconds"` + GuestRefreshTTLSeconds int `koanf:"guest_refresh_ttl_seconds"` // Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/). // When both fields are set, /api/calls/credentials returns short-lived @@ -83,31 +86,26 @@ type Config struct { VAPIDPrivateKey string `koanf:"vapid_private_key"` VAPIDSubject string `koanf:"vapid_subject"` - // 扫码登录(AUTH.md §4)+ cdrop 自签会话(§3)。cdrop 为「没有 IdP refresh_token」 - // 的会话(扫码批准的设备、受限访客借用)自签短效 access token(HS256,密钥派生自 - // SessionSecret)。QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503;其余为各 TTL。 - QRLoginEnabled bool `koanf:"qr_login_enabled"` - QRRequestTTLSeconds int `koanf:"qr_request_ttl_seconds"` - QRGuestTTLSeconds int `koanf:"qr_guest_ttl_seconds"` - QRPersistTTLHours int `koanf:"qr_persist_ttl_hours"` - SessionTokenTTLSeconds int `koanf:"session_token_ttl_seconds"` - - // StepUpEnabled 给敏感动作(批准扫码设备)加 provider 再认证门(AUTH.md §6):开启后 - // qr/approve 必须带一份新鲜的 prompt=login 授权码,后端就地换取 id_token、验签并校验 - // auth_time 在窗口内。默认关。StepUpMaxAgeSeconds 是该新鲜度窗口(秒),默认 300。 - StepUpEnabled bool `koanf:"step_up_enabled"` - StepUpMaxAgeSeconds int `koanf:"step_up_max_age_seconds"` - - // AccountMatchClaim 是写入 accounts.match_key 的 JWT claim,供管理员开启「迁移 - // 标记」时跨 provider 关联同一账号(AUTH.md §2.1/§7)。默认 email。 - AccountMatchClaim string `koanf:"account_match_claim"` + // 扫码登录(QR):新设备出码、已登录设备扫码批准,经 Auth Broker 委托签发会话。 + // QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503。QRRequestTTLSeconds 是一条扫码 + // 请求的存活秒数(默认 120)。批准后铸的会话寿命取上面的按档 TTL(full / guest)。 + QRLoginEnabled bool `koanf:"qr_login_enabled"` + QRRequestTTLSeconds int `koanf:"qr_request_ttl_seconds"` } -// QRLoginOn reports whether scan-login is enabled and the self-signed session -// machinery it relies on is keyable (SessionSecret present). Without the secret -// cdrop can't sign session tokens, so QR stays off regardless of the flag. +// QRLoginOn reports whether scan-login is enabled and the broker it delegates +// minting to is configured. Without BrokerBaseURL cdrop can't mint a session on +// approval, so QR stays off regardless of the flag. func (c *Config) QRLoginOn() bool { - return c.QRLoginEnabled && c.SessionSecret != "" + return c.QRLoginEnabled && c.BrokerBaseURL != "" +} + +// BrokerAppOrDefault returns the configured broker app key, defaulting to "cdrop". +func (c *Config) BrokerAppOrDefault() string { + if c.BrokerApp != "" { + return c.BrokerApp + } + return "cdrop" } // Load reads config from optional ./config.yaml then overrides with CDROP_* env. @@ -119,18 +117,15 @@ func Load() (*Config, error) { k.Set("db_path", "./cdrop.db") k.Set("listen", ":8080") k.Set("device_ttl_hours", 196) - k.Set("oidc_scopes", "openid profile email") k.Set("clipboard_max_bytes", 65536) k.Set("clipboard_debounce_sec", 3) - k.Set("shortcut_token_ttl_days", 365) - k.Set("shortcut_max_per_user", 10) k.Set("qr_login_enabled", true) k.Set("qr_request_ttl_seconds", 120) - k.Set("qr_guest_ttl_seconds", 3600) - k.Set("qr_persist_ttl_hours", 168) - k.Set("session_token_ttl_seconds", 900) - k.Set("account_match_claim", "email") - k.Set("step_up_max_age_seconds", 300) + k.Set("broker_app", "cdrop") + k.Set("full_access_ttl_seconds", 900) + k.Set("full_refresh_ttl_seconds", 604800) + k.Set("guest_access_ttl_seconds", 900) + k.Set("guest_refresh_ttl_seconds", 86400) if _, err := os.Stat("config.yaml"); err == nil { if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil { @@ -166,24 +161,27 @@ func (c *Config) validate() error { return errors.New("CDROP_AUTH_MODE=dev requires CDROP_DEV_TOKEN; refusing to start") } case "prod": - // Audience MUST be set in prod (R6). With it empty, RS256 audience - // checking is skipped entirely, so ANY token the IdP mints for ANY - // application sharing this JWKS would validate here — a user of a - // sibling Casdoor app could impersonate a cdrop user. The web + desktop - // client_ids go in CDROP_OIDC_AUDIENCE (comma-separated); refuse to boot - // without it rather than run wide open. - if c.OIDCAudience == "" { + // cdrop delegates identity + session lifecycle to the Auth Broker (path A): + // without its internal address and shared key, QR-approve can't mint and the + // service is non-functional. Refuse to boot rather than half-run. + if c.BrokerBaseURL == "" { return errors.New( - "CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " + - "(comma-separated OAuth client_ids); refusing to start") + "CDROP_AUTH_MODE=prod requires CDROP_BROKER_BASE_URL " + + "(the Auth Broker internal address); refusing to start") } - // SessionSecret keys at-rest encryption of browser refresh_tokens. Empty - // would either disable passwordless re-login or, worse, tempt a plaintext - // fallback; require it so the encryption path is always live in prod. - if c.SessionSecret == "" { + if c.BrokerInternalKey == "" { return errors.New( - "CDROP_AUTH_MODE=prod requires CDROP_SESSION_SECRET " + - "(keys web-session refresh_token encryption); refusing to start") + "CDROP_AUTH_MODE=prod requires CDROP_BROKER_INTERNAL_KEY " + + "(shared secret for the broker /internal/* API); refusing to start") + } + // PublicURL fixes the deployment origin the CSRF Origin check compares against + // (deriveSiteOrigin → sameOrigin). Without it sameOrigin fails OPEN, silently + // disabling the only cdrop-side CSRF guard on the state-changing POST endpoints + // (device-session mint, logout). Require it in prod so the guard never fails open. + if c.PublicURL == "" { + return errors.New( + "CDROP_AUTH_MODE=prod requires CDROP_PUBLIC_URL " + + "(the deployment origin, used for the CSRF check); refusing to start") } default: return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode) @@ -212,7 +210,7 @@ func (c *Config) VAPIDSubjectOrDefault() string { if c.VAPIDSubject != "" { return c.VAPIDSubject } - if u, err := url.Parse(c.OIDCRedirectURI); err == nil && u.Scheme != "" && u.Host != "" { + if u, err := url.Parse(c.PublicURL); err == nil && u.Scheme != "" && u.Host != "" { return u.Scheme + "://" + u.Host } return "https://drop.commilitia.net" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0da545e..9118c39 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -24,35 +24,48 @@ func TestValidate_DevWithDevTokenOK(t *testing.T) { } func TestValidate_ProdOK(t *testing.T) { - c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web,cdrop-desktop", SessionSecret: "s3cr3t"} + c := &Config{AuthMode: "prod", BrokerBaseURL: "http://broker:8080", BrokerInternalKey: "s3cr3t", PublicURL: "https://drop.example.net"} if err := c.validate(); err != nil { - t.Fatalf("prod mode with audience + session secret should pass; got %v", err) + t.Fatalf("prod mode with broker config should pass; got %v", err) } } -func TestValidate_ProdRequiresSessionSecret(t *testing.T) { - // SessionSecret keys at-rest encryption of browser refresh_tokens; without it - // passwordless re-login can't run safely. Refuse to boot in prod. - c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web", SessionSecret: ""} +func TestValidate_ProdRequiresPublicURL(t *testing.T) { + // PublicURL fixes the CSRF Origin check's expected value; without it sameOrigin fails + // open, silently disabling the guard on the state-changing POST endpoints. Refuse to boot. + c := &Config{AuthMode: "prod", BrokerBaseURL: "http://broker:8080", BrokerInternalKey: "k", PublicURL: ""} err := c.validate() if err == nil { - t.Fatal("prod without CDROP_SESSION_SECRET must reject; got nil error") + t.Fatal("prod without CDROP_PUBLIC_URL must reject; got nil error") } - if !strings.Contains(err.Error(), "CDROP_SESSION_SECRET") { - t.Errorf("error must mention CDROP_SESSION_SECRET; got %q", err.Error()) + if !strings.Contains(err.Error(), "CDROP_PUBLIC_URL") { + t.Errorf("error must mention CDROP_PUBLIC_URL; got %q", err.Error()) } } -func TestValidate_ProdRequiresAudience(t *testing.T) { - // R6: prod with an empty audience leaves RS256 audience checking off, so a - // token minted for any sibling app sharing the JWKS would validate. Refuse. - c := &Config{AuthMode: "prod", OIDCAudience: ""} +func TestValidate_ProdRequiresBrokerBaseURL(t *testing.T) { + // cdrop delegates session lifecycle to the Auth Broker; without its address the + // service can't mint on approval. Refuse to boot in prod. + c := &Config{AuthMode: "prod", BrokerBaseURL: "", BrokerInternalKey: "k"} err := c.validate() if err == nil { - t.Fatal("prod without CDROP_OIDC_AUDIENCE must reject; got nil error") + t.Fatal("prod without CDROP_BROKER_BASE_URL must reject; got nil error") } - if !strings.Contains(err.Error(), "CDROP_OIDC_AUDIENCE") { - t.Errorf("error must mention CDROP_OIDC_AUDIENCE; got %q", err.Error()) + if !strings.Contains(err.Error(), "CDROP_BROKER_BASE_URL") { + t.Errorf("error must mention CDROP_BROKER_BASE_URL; got %q", err.Error()) + } +} + +func TestValidate_ProdRequiresBrokerInternalKey(t *testing.T) { + // The shared key guards the broker's /internal/* mint API; without it cdrop + // can't authenticate to the broker. Refuse to boot in prod. + c := &Config{AuthMode: "prod", BrokerBaseURL: "http://broker:8080", BrokerInternalKey: ""} + err := c.validate() + if err == nil { + t.Fatal("prod without CDROP_BROKER_INTERNAL_KEY must reject; got nil error") + } + if !strings.Contains(err.Error(), "CDROP_BROKER_INTERNAL_KEY") { + t.Errorf("error must mention CDROP_BROKER_INTERNAL_KEY; got %q", err.Error()) } } @@ -91,7 +104,7 @@ func TestVAPIDSubjectOrDefault(t *testing.T) { if got := explicit.VAPIDSubjectOrDefault(); got != "mailto:ops@example.com" { t.Fatalf("explicit subject: got %q", got) } - derived := &Config{OIDCRedirectURI: "https://drop.example.com/oauth/callback"} + derived := &Config{PublicURL: "https://drop.example.com"} if got := derived.VAPIDSubjectOrDefault(); got != "https://drop.example.com" { t.Fatalf("derived subject: got %q, want scheme://host", got) } diff --git a/internal/db/accounts.sql.go b/internal/db/accounts.sql.go deleted file mode 100644 index 0b5e3ea..0000000 --- a/internal/db/accounts.sql.go +++ /dev/null @@ -1,76 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: accounts.sql - -package db - -import ( - "context" -) - -const getAccount = `-- name: GetAccount :one -SELECT user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at -FROM accounts -WHERE user_id = ? -` - -func (q *Queries) GetAccount(ctx context.Context, userID string) (Account, error) { - row := q.db.QueryRowContext(ctx, getAccount, userID) - var i Account - err := row.Scan( - &i.UserID, - &i.MatchKey, - &i.DisplayName, - &i.AvatarUrl, - &i.Roles, - &i.Provider, - &i.CreatedAt, - &i.LastLoginAt, - ) - return i, err -} - -const upsertAccount = `-- name: UpsertAccount :exec - -INSERT INTO accounts (user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(user_id) DO UPDATE SET - match_key = excluded.match_key, - display_name = excluded.display_name, - avatar_url = excluded.avatar_url, - roles = excluded.roles, - provider = excluded.provider, - last_login_at = excluded.last_login_at -` - -type UpsertAccountParams struct { - UserID string `json:"user_id"` - MatchKey string `json:"match_key"` - DisplayName string `json:"display_name"` - AvatarUrl string `json:"avatar_url"` - Roles string `json:"roles"` - Provider string `json:"provider"` - CreatedAt int64 `json:"created_at"` - LastLoginAt int64 `json:"last_login_at"` -} - -// accounts: cdrop-side thin account data (AUTH.md 2.1). Keyed on user_id (= OIDC -// sub). No credentials live here; password / second factor stay at the OAuth -// provider. Upserted on every successful OIDC exchange. -// -// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and -// corrupts every generated SQL const in the file. Keep this file pure ASCII. -func (q *Queries) UpsertAccount(ctx context.Context, arg UpsertAccountParams) error { - _, err := q.db.ExecContext(ctx, upsertAccount, - arg.UserID, - arg.MatchKey, - arg.DisplayName, - arg.AvatarUrl, - arg.Roles, - arg.Provider, - arg.CreatedAt, - arg.LastLoginAt, - ) - return err -} diff --git a/internal/db/bootstrap.go b/internal/db/bootstrap.go index 8d01ce6..baaaa9c 100644 --- a/internal/db/bootstrap.go +++ b/internal/db/bootstrap.go @@ -59,24 +59,12 @@ func Bootstrap(ctx context.Context, d *sql.DB) error { "ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil { return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err) } - // web_sessions gained kind/scope/granted_by for cdrop self-signed sessions - // (AUTH.md §2.2). Existing rows predate these columns; backfill them so a - // pre-existing browser session keeps behaving as a normal IdP-backed login. - if err := ensureColumn(ctx, tx, "web_sessions", "kind", - "ALTER TABLE web_sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'oidc'"); err != nil { - return fmt.Errorf("migrate web_sessions.kind: %w", err) - } - if err := ensureColumn(ctx, tx, "web_sessions", "scope", - "ALTER TABLE web_sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'"); err != nil { - return fmt.Errorf("migrate web_sessions.scope: %w", err) - } - if err := ensureColumn(ctx, tx, "web_sessions", "granted_by", - "ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''"); err != nil { - return fmt.Errorf("migrate web_sessions.granted_by: %w", err) - } - if err := ensureColumn(ctx, tx, "web_sessions", "stepped_up_at", - "ALTER TABLE web_sessions ADD COLUMN stepped_up_at INTEGER NOT NULL DEFAULT 0"); err != nil { - return fmt.Errorf("migrate web_sessions.stepped_up_at: %w", err) + // devices moved to a stable opaque device_id primary key (Auth Broker path A); + // the old table was keyed (user_id, name). Device rows are ephemeral registrations + // re-created on the next request / scan, so a legacy table is dropped and recreated + // rather than column-migrated (SQLite can't ALTER a primary key in place). + if err := recreateDevicesIfLegacy(ctx, tx); err != nil { + return fmt.Errorf("migrate devices to device_id: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit bootstrap: %w", err) @@ -84,6 +72,41 @@ func Bootstrap(ctx context.Context, d *sql.DB) error { return nil } +// recreateDevicesIfLegacy drops and recreates the devices table when it predates the +// device_id primary key (the legacy (user_id, name) shape). On a fresh DB the init +// schema already created the new shape, so device_id is present and this no-ops. The +// dropped rows are ephemeral device registrations, re-created on the next request or +// scan-login, so no durable data is lost. +func recreateDevicesIfLegacy(ctx context.Context, tx *sql.Tx) error { + has, err := columnExists(ctx, tx, "devices", "device_id") + if err != nil { + return err + } + if has { + return nil + } + stmts := []string{ + "DROP TABLE IF EXISTS devices", + `CREATE TABLE devices ( + device_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + tier TEXT NOT NULL DEFAULT 'full', + broker_sid TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL + )`, + "CREATE INDEX IF NOT EXISTS idx_devices_user ON devices (user_id)", + } + for _, s := range stmts { + if _, err := tx.ExecContext(ctx, s); err != nil { + return err + } + } + return nil +} + // ensureColumn runs addSQL only when table lacks the named column — an idempotent // additive migration for already-created tables. func ensureColumn(ctx context.Context, tx *sql.Tx, table, column, addSQL string) error { diff --git a/internal/db/bootstrap_test.go b/internal/db/bootstrap_test.go index 95fcfef..458aadd 100644 --- a/internal/db/bootstrap_test.go +++ b/internal/db/bootstrap_test.go @@ -19,7 +19,7 @@ func TestBootstrapCreatesAllTables(t *testing.T) { t.Fatalf("bootstrap: %v", err) } - want := []string{"accounts", "clipboard_state", "devices", "login_requests", "push_subscriptions", "shortcut_tokens", "transfer_sessions", "web_sessions"} + want := []string{"clipboard_state", "devices", "login_requests", "push_subscriptions", "transfer_sessions"} rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") if err != nil { t.Fatalf("query tables: %v", err) @@ -115,10 +115,11 @@ func TestBootstrapAddsClipboardVersionToLegacyDB(t *testing.T) { } } -// Simulates the prod upgrade for scan-login: a web_sessions table created before -// kind/scope/granted_by existed must gain them, with an existing row defaulting -// to a normal OIDC-backed full session so its behaviour is unchanged (AUTH.md §2.2). -func TestBootstrapAddsWebSessionColumnsToLegacyDB(t *testing.T) { +// Simulates the prod upgrade to the Auth Broker model: a legacy devices table keyed +// (user_id, name) must be recreated with the device_id primary key. The ephemeral +// device rows are dropped (re-created on the next request / scan), and the migration +// is idempotent. +func TestBootstrapRecreatesLegacyDevices(t *testing.T) { tmp := filepath.Join(t.TempDir(), "legacy.db") d, err := Open(tmp) if err != nil { @@ -126,15 +127,12 @@ func TestBootstrapAddsWebSessionColumnsToLegacyDB(t *testing.T) { } defer d.Close() - _, err = d.Exec(`CREATE TABLE web_sessions ( - id TEXT PRIMARY KEY, user_id TEXT NOT NULL, refresh_token TEXT NOT NULL, - device_name TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`) - if err != nil { + if _, err := d.Exec(`CREATE TABLE devices ( + user_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL, + last_seen INTEGER NOT NULL, PRIMARY KEY (user_id, name))`); err != nil { t.Fatalf("legacy schema: %v", err) } - if _, err := d.Exec(`INSERT INTO web_sessions (id, user_id, refresh_token, created_at, last_used_at, expires_at) - VALUES ('s', 'u', 'enc', 1, 1, 9999999999)`); err != nil { + if _, err := d.Exec(`INSERT INTO devices (user_id, name, type, last_seen) VALUES ('u','old','browser',1)`); err != nil { t.Fatalf("seed row: %v", err) } @@ -142,17 +140,29 @@ func TestBootstrapAddsWebSessionColumnsToLegacyDB(t *testing.T) { t.Fatalf("bootstrap: %v", err) } - var kind, scope, grantedBy string - if err := d.QueryRow(`SELECT kind, scope, granted_by FROM web_sessions WHERE id='s'`). - Scan(&kind, &scope, &grantedBy); err != nil { - t.Fatalf("select new columns (missing?): %v", err) + // The table now has the device_id-keyed shape (this insert would fail otherwise). + if _, err := d.Exec(`INSERT INTO devices (device_id, user_id, name, type, tier, broker_sid, created_at, last_seen) + VALUES ('dev_1','u','New','browser','full','sid',1,1)`); err != nil { + t.Fatalf("new schema insert (device_id missing?): %v", err) } - if kind != "oidc" || scope != "full" || grantedBy != "" { - t.Errorf("legacy row defaults: got kind=%q scope=%q granted_by=%q, want oidc/full/empty", - kind, scope, grantedBy) + // The legacy row was dropped with the table. + var legacy int + if err := d.QueryRow(`SELECT COUNT(*) FROM devices WHERE name='old'`).Scan(&legacy); err != nil { + t.Fatalf("count legacy: %v", err) } - // Idempotent: second bootstrap must not error on already-present columns. + if legacy != 0 { + t.Errorf("legacy device row should be dropped on recreate; got %d", legacy) + } + + // Idempotent: a second bootstrap leaves the new shape and its rows intact. if err := Bootstrap(context.Background(), d); err != nil { t.Fatalf("second bootstrap: %v", err) } + var kept int + if err := d.QueryRow(`SELECT COUNT(*) FROM devices WHERE device_id='dev_1'`).Scan(&kept); err != nil { + t.Fatalf("count after second bootstrap: %v", err) + } + if kept != 1 { + t.Errorf("device row should survive idempotent bootstrap; got %d", kept) + } } diff --git a/internal/db/devices.sql.go b/internal/db/devices.sql.go index 528ea91..db89578 100644 --- a/internal/db/devices.sql.go +++ b/internal/db/devices.sql.go @@ -9,41 +9,70 @@ import ( "context" ) +const createDevice = `-- name: CreateDevice :exec + +INSERT INTO devices (device_id, user_id, name, type, tier, broker_sid, created_at, last_seen) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (device_id) DO UPDATE SET + name = excluded.name, + type = excluded.type, + tier = excluded.tier, + broker_sid = excluded.broker_sid, + last_seen = excluded.last_seen +WHERE devices.user_id = excluded.user_id +` + +type CreateDeviceParams struct { + DeviceID string `json:"device_id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Type string `json:"type"` + Tier string `json:"tier"` + BrokerSid string `json:"broker_sid"` + CreatedAt int64 `json:"created_at"` + LastSeen int64 `json:"last_seen"` +} + +// devices: cdrop-managed devices (scan-login / native pairing). device_id is a +// stable opaque cdrop-generated id that doubles as the session<->device join key +// (passed to the broker as meta, echoed back as X-Auth-Meta on /verify). broker_sid +// is the broker's session id, stored privately to revoke on device removal. tier is +// a redundant cache of the broker scope (full/guest). name is human-readable and can +// change without changing the device's identity (device_id is the key). +// +// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and corrupts +// every generated SQL const in the file. Keep this file pure ASCII. +// CreateDevice records a device on scan-login collect / proxy-mint (or native pairing). Keyed +// on device_id, so a re-pair with the same id refreshes the row (incl. the new broker_sid). +// The WHERE on the upsert scopes the update to the owning user so a (cryptographically +// impossible) cross-user device_id collision can never reassign the row owner; user_id is +// immutable for a given device_id. +func (q *Queries) CreateDevice(ctx context.Context, arg CreateDeviceParams) error { + _, err := q.db.ExecContext(ctx, createDevice, + arg.DeviceID, + arg.UserID, + arg.Name, + arg.Type, + arg.Tier, + arg.BrokerSid, + arg.CreatedAt, + arg.LastSeen, + ) + return err +} + const deleteDevice = `-- name: DeleteDevice :execrows DELETE FROM devices -WHERE user_id = ? AND name = ? +WHERE device_id = ? AND user_id = ? ` type DeleteDeviceParams struct { - UserID string `json:"user_id"` - Name string `json:"name"` + DeviceID string `json:"device_id"` + UserID string `json:"user_id"` } func (q *Queries) DeleteDevice(ctx context.Context, arg DeleteDeviceParams) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteDevice, arg.UserID, arg.Name) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -const deleteOrphanBrowserDevices = `-- name: DeleteOrphanBrowserDevices :execrows -DELETE FROM devices -WHERE type = 'browser' - AND NOT EXISTS ( - SELECT 1 FROM web_sessions ws - WHERE ws.user_id = devices.user_id - AND ws.device_name = devices.name - AND ws.expires_at > ? - ) -` - -// DeleteOrphanBrowserDevices removes browser device rows with no live web_session -// (the session was revoked or expired), keeping the device list aligned with the -// session list. The ? is the current epoch second. Native (macos/windows/linux/ios) -// and shortcut devices are left alone: they legitimately keep no web_session. -func (q *Queries) DeleteOrphanBrowserDevices(ctx context.Context, expiresAt int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteOrphanBrowserDevices, expiresAt) + result, err := q.db.ExecContext(ctx, deleteDevice, arg.DeviceID, arg.UserID) if err != nil { return 0, err } @@ -60,8 +89,30 @@ func (q *Queries) DeleteStaleDevices(ctx context.Context, lastSeen int64) error return err } +const getDevice = `-- name: GetDevice :one +SELECT device_id, user_id, name, type, tier, broker_sid, created_at, last_seen +FROM devices +WHERE device_id = ? +` + +func (q *Queries) GetDevice(ctx context.Context, deviceID string) (Device, error) { + row := q.db.QueryRowContext(ctx, getDevice, deviceID) + var i Device + err := row.Scan( + &i.DeviceID, + &i.UserID, + &i.Name, + &i.Type, + &i.Tier, + &i.BrokerSid, + &i.CreatedAt, + &i.LastSeen, + ) + return i, err +} + const listDevicesByUser = `-- name: ListDevicesByUser :many -SELECT user_id, name, type, last_seen +SELECT device_id, user_id, name, type, tier, broker_sid, created_at, last_seen FROM devices WHERE user_id = ? ORDER BY name @@ -77,9 +128,13 @@ func (q *Queries) ListDevicesByUser(ctx context.Context, userID string) ([]Devic for rows.Next() { var i Device if err := rows.Scan( + &i.DeviceID, &i.UserID, &i.Name, &i.Type, + &i.Tier, + &i.BrokerSid, + &i.CreatedAt, &i.LastSeen, ); err != nil { return nil, err @@ -95,27 +150,45 @@ func (q *Queries) ListDevicesByUser(ctx context.Context, userID string) ([]Devic return items, nil } -const upsertDevice = `-- name: UpsertDevice :exec -INSERT INTO devices (user_id, name, type, last_seen) -VALUES (?, ?, ?, ?) -ON CONFLICT (user_id, name) DO UPDATE SET - type = excluded.type, - last_seen = excluded.last_seen +const renameDevice = `-- name: RenameDevice :execrows +UPDATE devices SET name = ? +WHERE device_id = ? AND user_id = ? ` -type UpsertDeviceParams struct { - UserID string `json:"user_id"` +type RenameDeviceParams struct { Name string `json:"name"` - Type string `json:"type"` - LastSeen int64 `json:"last_seen"` + DeviceID string `json:"device_id"` + UserID string `json:"user_id"` } -func (q *Queries) UpsertDevice(ctx context.Context, arg UpsertDeviceParams) error { - _, err := q.db.ExecContext(ctx, upsertDevice, - arg.UserID, - arg.Name, - arg.Type, +func (q *Queries) RenameDevice(ctx context.Context, arg RenameDeviceParams) (int64, error) { + result, err := q.db.ExecContext(ctx, renameDevice, arg.Name, arg.DeviceID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const touchDevice = `-- name: TouchDevice :exec +UPDATE devices SET last_seen = ?, tier = ? WHERE device_id = ? AND user_id = ? +` + +type TouchDeviceParams struct { + LastSeen int64 `json:"last_seen"` + Tier string `json:"tier"` + DeviceID string `json:"device_id"` + UserID string `json:"user_id"` +} + +// TouchDevice refreshes last_seen + tier on each authenticated request. Update-only: +// the row is created at collect time, so a missing row (e.g. a device authorized via +// the broker's own device flow, not cdrop's) simply isn't cdrop-managed and no-ops. +func (q *Queries) TouchDevice(ctx context.Context, arg TouchDeviceParams) error { + _, err := q.db.ExecContext(ctx, touchDevice, arg.LastSeen, + arg.Tier, + arg.DeviceID, + arg.UserID, ) return err } diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql index efe7ab7..d802575 100644 --- a/internal/db/migrations/0001_init.sql +++ b/internal/db/migrations/0001_init.sql @@ -1,24 +1,23 @@ -- cdrop MVP schema (PROJECT_BRIEF.md §4) -- 以 IF NOT EXISTS 形式由 internal/db/bootstrap.go 启动期单事务执行。 +-- devices:cdrop 托管设备(扫码批准 / 原生配对的设备)。device_id 是 cdrop 生成的 +-- 稳定不透明 id,作设备身份与「会话↔设备」的 join key——经 broker mint 时作 meta 传入、 +-- 由 broker /verify 回显成 X-Auth-Meta。broker_sid 是 broker 返回的会话 id,私存用于 +-- 吊销(删设备)。tier=full/guest(冗余缓存,权威在 broker scope)。name 是人类可读 +-- 设备名,可重命名而身份不变(device_id 不变)。 CREATE TABLE IF NOT EXISTS devices ( - user_id TEXT NOT NULL, - name TEXT NOT NULL, - type TEXT NOT NULL, - last_seen INTEGER NOT NULL, - PRIMARY KEY (user_id, name) + device_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + tier TEXT NOT NULL DEFAULT 'full', + broker_sid TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS shortcut_tokens ( - jti TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - label TEXT NOT NULL, - scopes TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - last_used_at INTEGER, - revoked INTEGER NOT NULL DEFAULT 0 -); +CREATE INDEX IF NOT EXISTS idx_devices_user ON devices (user_id); CREATE TABLE IF NOT EXISTS transfer_sessions ( id TEXT PRIMARY KEY, @@ -50,37 +49,6 @@ CREATE TABLE IF NOT EXISTS clipboard_state ( origin_ts INTEGER NOT NULL DEFAULT 0 ); --- web_sessions:浏览器端「免重登」。cookie 里只放不透明随机串,本表 id 存其 --- SHA-256,故 DB 单独泄露也换不出可用 cookie;refresh_token 以 AES-256-GCM 落盘 --- (密钥在容器环境、不在库内)。仅浏览器使用——桌面端 refresh_token 由 Go keyring --- 持有、走自有 loopback flow,绝不入此表。device_name 随会话存,使 PWA 存储被清后 --- 开机仍能恢复本机设备名。 -CREATE TABLE IF NOT EXISTS web_sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - refresh_token TEXT NOT NULL, - device_name TEXT NOT NULL DEFAULT '', - user_agent TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, - last_used_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - -- kind 区分会话来源(AUTH.md §2.2):oidc=绑定 IdP refresh_token 的正常浏览器 - -- 登录(refresh 时向 IdP 续);self/guest=cdrop 自签会话,无 IdP refresh_token, - -- refresh 时仅按本行自签 access token、不触 IdP。既有行经 bootstrap 的幂等 ALTER - -- 补列后默认视为 oidc/full,行为不变。 - kind TEXT NOT NULL DEFAULT 'oidc', - -- scope=full/guest。guest(扫码受限借用设备)服务端强制受限:能收发文件, - -- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。 - scope TEXT NOT NULL DEFAULT 'full', - -- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。 - granted_by TEXT NOT NULL DEFAULT '', - -- stepped_up_at=本会话最近一次通过 step-up 再认证的 Unix 秒(AUTH.md §6)。在 - -- StepUpMaxAgeSeconds 窗口内,敏感动作不再重复要求再认证(避免每次都弹再登录)。 - stepped_up_at INTEGER NOT NULL DEFAULT 0 -); - -CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at); - -- push_subscriptions:浏览器 / PWA 的 Web Push 订阅端点。当某设备「页面已关闭」 -- (无活的 SSE 连接)时,服务端按此表向其推送系统通知;页面开着时事件仍走 SSE, -- 前端自弹 toast,不触发 push。id 是 SHA-256(endpoint) 的 hex,使同一浏览器重复 @@ -105,24 +73,6 @@ CREATE TABLE IF NOT EXISTS push_subscriptions ( CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_device ON push_subscriptions (user_id, device_name); --- accounts:cdrop 侧「薄账户数据层」(AUTH.md §2.1)。身份来源仍是 OAuth provider, --- user_id 继续等于 OIDC sub;本表只存 cdrop 自维护的账户元数据,不含任何凭证(密码 / --- 第二因子全在 provider)。OIDC exchange 成功后 upsert:捕获 match_key(默认 email --- claim,仅在管理员开启「迁移标记」时用作跨源关联的 join key)、显示名 / 头像、roles --- (groups claim 缓存,admin 判定用)。 -CREATE TABLE IF NOT EXISTS accounts ( - user_id TEXT PRIMARY KEY, - match_key TEXT NOT NULL DEFAULT '', - display_name TEXT NOT NULL DEFAULT '', - avatar_url TEXT NOT NULL DEFAULT '', - roles TEXT NOT NULL DEFAULT '', - provider TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, - last_login_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_accounts_match_key ON accounts (match_key); - -- login_requests:扫码登录的待批准请求(AUTH.md §2.3、§4)。三方密钥分离—— -- poll_secret 存其 SHA-256(新设备私有、QR 不含、领取会话凭它);approval_code 随 QR -- 给批准方。短 TTL(默认 120s),consumed/expired 后不可复用,由 reaper 清理。 diff --git a/internal/db/models.go b/internal/db/models.go index d102f73..a702e27 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -4,17 +4,6 @@ package db -type Account struct { - UserID string `json:"user_id"` - MatchKey string `json:"match_key"` - DisplayName string `json:"display_name"` - AvatarUrl string `json:"avatar_url"` - Roles string `json:"roles"` - Provider string `json:"provider"` - CreatedAt int64 `json:"created_at"` - LastLoginAt int64 `json:"last_login_at"` -} - type ClipboardState struct { UserID string `json:"user_id"` ContentType string `json:"content_type"` @@ -26,10 +15,14 @@ type ClipboardState struct { } type Device struct { - UserID string `json:"user_id"` - Name string `json:"name"` - Type string `json:"type"` - LastSeen int64 `json:"last_seen"` + DeviceID string `json:"device_id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Type string `json:"type"` + Tier string `json:"tier"` + BrokerSid string `json:"broker_sid"` + CreatedAt int64 `json:"created_at"` + LastSeen int64 `json:"last_seen"` } type LoginRequest struct { @@ -62,17 +55,6 @@ type PushSubscription struct { LastUsedAt int64 `json:"last_used_at"` } -type ShortcutToken struct { - Jti string `json:"jti"` - UserID string `json:"user_id"` - Label string `json:"label"` - Scopes string `json:"scopes"` - CreatedAt int64 `json:"created_at"` - ExpiresAt int64 `json:"expires_at"` - LastUsedAt *int64 `json:"last_used_at"` - Revoked int64 `json:"revoked"` -} - type TransferSession struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -88,18 +70,3 @@ type TransferSession struct { FinishedAt *int64 `json:"finished_at"` FailReason *string `json:"fail_reason"` } - -type WebSession struct { - ID string `json:"id"` - UserID string `json:"user_id"` - RefreshToken string `json:"refresh_token"` - DeviceName string `json:"device_name"` - UserAgent string `json:"user_agent"` - CreatedAt int64 `json:"created_at"` - LastUsedAt int64 `json:"last_used_at"` - ExpiresAt int64 `json:"expires_at"` - Kind string `json:"kind"` - Scope string `json:"scope"` - GrantedBy string `json:"granted_by"` - SteppedUpAt int64 `json:"stepped_up_at"` -} diff --git a/internal/db/queries/accounts.sql b/internal/db/queries/accounts.sql deleted file mode 100644 index 192ee3c..0000000 --- a/internal/db/queries/accounts.sql +++ /dev/null @@ -1,22 +0,0 @@ --- accounts: cdrop-side thin account data (AUTH.md 2.1). Keyed on user_id (= OIDC --- sub). No credentials live here; password / second factor stay at the OAuth --- provider. Upserted on every successful OIDC exchange. --- --- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and --- corrupts every generated SQL const in the file. Keep this file pure ASCII. - --- name: UpsertAccount :exec -INSERT INTO accounts (user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(user_id) DO UPDATE SET - match_key = excluded.match_key, - display_name = excluded.display_name, - avatar_url = excluded.avatar_url, - roles = excluded.roles, - provider = excluded.provider, - last_login_at = excluded.last_login_at; - --- name: GetAccount :one -SELECT user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at -FROM accounts -WHERE user_id = ?; diff --git a/internal/db/queries/devices.sql b/internal/db/queries/devices.sql index ff6b14a..d449067 100644 --- a/internal/db/queries/devices.sql +++ b/internal/db/queries/devices.sql @@ -1,34 +1,54 @@ --- name: UpsertDevice :exec -INSERT INTO devices (user_id, name, type, last_seen) -VALUES (?, ?, ?, ?) -ON CONFLICT (user_id, name) DO UPDATE SET +-- devices: cdrop-managed devices (scan-login / native pairing). device_id is a +-- stable opaque cdrop-generated id that doubles as the session<->device join key +-- (passed to the broker as meta, echoed back as X-Auth-Meta on /verify). broker_sid +-- is the broker's session id, stored privately to revoke on device removal. tier is +-- a redundant cache of the broker scope (full/guest). name is human-readable and can +-- change without changing the device's identity (device_id is the key). +-- +-- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and corrupts +-- every generated SQL const in the file. Keep this file pure ASCII. + +-- CreateDevice records a device on scan-login collect / proxy-mint (or native pairing). Keyed +-- on device_id, so a re-pair with the same id refreshes the row (incl. the new broker_sid). +-- The WHERE on the upsert scopes the update to the owning user so a (cryptographically +-- impossible) cross-user device_id collision can never reassign the row owner; user_id is +-- immutable for a given device_id. +-- name: CreateDevice :exec +INSERT INTO devices (device_id, user_id, name, type, tier, broker_sid, created_at, last_seen) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (device_id) DO UPDATE SET + name = excluded.name, type = excluded.type, - last_seen = excluded.last_seen; + tier = excluded.tier, + broker_sid = excluded.broker_sid, + last_seen = excluded.last_seen +WHERE devices.user_id = excluded.user_id; + +-- TouchDevice refreshes last_seen + tier on each authenticated request. Update-only: +-- the row is created at collect time, so a missing row (e.g. a device authorized via +-- the broker's own device flow, not cdrop's) simply isn't cdrop-managed and no-ops. +-- name: TouchDevice :exec +UPDATE devices SET last_seen = ?, tier = ? WHERE device_id = ? AND user_id = ?; -- name: ListDevicesByUser :many -SELECT user_id, name, type, last_seen +SELECT device_id, user_id, name, type, tier, broker_sid, created_at, last_seen FROM devices WHERE user_id = ? ORDER BY name; --- name: DeleteStaleDevices :exec -DELETE FROM devices -WHERE last_seen < ?; - --- DeleteOrphanBrowserDevices removes browser device rows with no live web_session --- (the session was revoked or expired), keeping the device list aligned with the --- session list. The ? is the current epoch second. Native (macos/windows/linux/ios) --- and shortcut devices are left alone: they legitimately keep no web_session. --- name: DeleteOrphanBrowserDevices :execrows -DELETE FROM devices -WHERE type = 'browser' - AND NOT EXISTS ( - SELECT 1 FROM web_sessions ws - WHERE ws.user_id = devices.user_id - AND ws.device_name = devices.name - AND ws.expires_at > ? - ); +-- name: GetDevice :one +SELECT device_id, user_id, name, type, tier, broker_sid, created_at, last_seen +FROM devices +WHERE device_id = ?; -- name: DeleteDevice :execrows DELETE FROM devices -WHERE user_id = ? AND name = ?; +WHERE device_id = ? AND user_id = ?; + +-- name: RenameDevice :execrows +UPDATE devices SET name = ? +WHERE device_id = ? AND user_id = ?; + +-- name: DeleteStaleDevices :exec +DELETE FROM devices +WHERE last_seen < ?; diff --git a/internal/db/queries/shortcut_tokens.sql b/internal/db/queries/shortcut_tokens.sql deleted file mode 100644 index 0e4f49d..0000000 --- a/internal/db/queries/shortcut_tokens.sql +++ /dev/null @@ -1,32 +0,0 @@ --- shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the --- iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by --- user_id to block cross-user revocation; Touch records last use asynchronously. - --- name: InsertShortcutToken :exec -INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at) -VALUES (?, ?, ?, ?, ?, ?); - --- name: GetShortcutToken :one -SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked -FROM shortcut_tokens -WHERE jti = ?; - --- name: ListShortcutTokensByUser :many -SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked -FROM shortcut_tokens -WHERE user_id = ? -ORDER BY created_at DESC; - --- name: CountActiveShortcutTokensByUser :one -SELECT COUNT(*) FROM shortcut_tokens -WHERE user_id = ? AND revoked = 0 AND expires_at > ?; - --- name: RevokeShortcutToken :execrows -UPDATE shortcut_tokens -SET revoked = 1 -WHERE jti = ? AND user_id = ?; - --- name: TouchShortcutTokenUsed :exec -UPDATE shortcut_tokens -SET last_used_at = ? -WHERE jti = ?; diff --git a/internal/db/queries/web_sessions.sql b/internal/db/queries/web_sessions.sql deleted file mode 100644 index d41bb08..0000000 --- a/internal/db/queries/web_sessions.sql +++ /dev/null @@ -1,69 +0,0 @@ --- web_sessions: browser "passwordless re-login". The opaque cookie token is --- never stored; id holds its SHA-256, so a DB leak yields no usable cookie. --- refresh_token is AES-256-GCM ciphertext (key lives in the container env, not --- the DB). 7-day sliding window: every refresh rotates the token and pushes --- expires_at forward; idle sessions are reaped by DeleteExpiredWebSessions. - --- name: CreateWebSession :exec -INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?); - --- name: GetWebSession :one -SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at -FROM web_sessions -WHERE id = ?; - --- SetSessionSteppedUp stamps the moment this session last passed step-up re-auth; --- sensitive actions within the freshness window then skip re-auth (AUTH.md 6). --- name: SetSessionSteppedUp :exec -UPDATE web_sessions -SET stepped_up_at = ? -WHERE id = ?; - --- name: RotateWebSession :exec -UPDATE web_sessions -SET refresh_token = ?, last_used_at = ?, expires_at = ? -WHERE id = ?; - --- name: SetWebSessionDevice :exec -UPDATE web_sessions -SET device_name = ? -WHERE id = ?; - --- name: DeleteWebSession :exec -DELETE FROM web_sessions -WHERE id = ?; - --- name: DeleteExpiredWebSessions :execrows -DELETE FROM web_sessions -WHERE expires_at < ?; - --- CreateSelfSession inserts a cdrop self-signed session (kind self/guest) that has --- no IdP refresh_token (stored empty): refresh mints a fresh access token straight --- from this row without touching the IdP. Used by QR scan-login (AUTH.md 4). --- name: CreateSelfSession :exec -INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, kind, scope, granted_by, created_at, last_used_at, expires_at) -VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?); - --- SlideWebSession pushes a session's sliding window forward without rotating any --- token. Self/guest sessions use it on refresh (they have no refresh_token to --- rotate); oidc sessions keep using RotateWebSession. --- name: SlideWebSession :exec -UPDATE web_sessions -SET last_used_at = ?, expires_at = ? -WHERE id = ?; - --- ListWebSessionsByUser lists a user's sessions for the management UI (revoke --- borrowed / guest devices). granted_by ties a scan-approved session to its --- approver for cascade revocation. --- name: ListWebSessionsByUser :many -SELECT id, user_id, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by -FROM web_sessions -WHERE user_id = ? -ORDER BY last_used_at DESC; - --- DeleteWebSessionForUser drops one session scoped to its owner, so a user can --- only revoke their own. --- name: DeleteWebSessionForUser :execrows -DELETE FROM web_sessions -WHERE id = ? AND user_id = ?; diff --git a/internal/db/shortcut_tokens.sql.go b/internal/db/shortcut_tokens.sql.go deleted file mode 100644 index d4fca0f..0000000 --- a/internal/db/shortcut_tokens.sql.go +++ /dev/null @@ -1,153 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: shortcut_tokens.sql - -package db - -import ( - "context" -) - -const countActiveShortcutTokensByUser = `-- name: CountActiveShortcutTokensByUser :one -SELECT COUNT(*) FROM shortcut_tokens -WHERE user_id = ? AND revoked = 0 AND expires_at > ? -` - -type CountActiveShortcutTokensByUserParams struct { - UserID string `json:"user_id"` - ExpiresAt int64 `json:"expires_at"` -} - -func (q *Queries) CountActiveShortcutTokensByUser(ctx context.Context, arg CountActiveShortcutTokensByUserParams) (int64, error) { - row := q.db.QueryRowContext(ctx, countActiveShortcutTokensByUser, arg.UserID, arg.ExpiresAt) - var count int64 - err := row.Scan(&count) - return count, err -} - -const getShortcutToken = `-- name: GetShortcutToken :one -SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked -FROM shortcut_tokens -WHERE jti = ? -` - -func (q *Queries) GetShortcutToken(ctx context.Context, jti string) (ShortcutToken, error) { - row := q.db.QueryRowContext(ctx, getShortcutToken, jti) - var i ShortcutToken - err := row.Scan( - &i.Jti, - &i.UserID, - &i.Label, - &i.Scopes, - &i.CreatedAt, - &i.ExpiresAt, - &i.LastUsedAt, - &i.Revoked, - ) - return i, err -} - -const insertShortcutToken = `-- name: InsertShortcutToken :exec - -INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at) -VALUES (?, ?, ?, ?, ?, ?) -` - -type InsertShortcutTokenParams struct { - Jti string `json:"jti"` - UserID string `json:"user_id"` - Label string `json:"label"` - Scopes string `json:"scopes"` - CreatedAt int64 `json:"created_at"` - ExpiresAt int64 `json:"expires_at"` -} - -// shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the -// iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by -// user_id to block cross-user revocation; Touch records last use asynchronously. -func (q *Queries) InsertShortcutToken(ctx context.Context, arg InsertShortcutTokenParams) error { - _, err := q.db.ExecContext(ctx, insertShortcutToken, - arg.Jti, - arg.UserID, - arg.Label, - arg.Scopes, - arg.CreatedAt, - arg.ExpiresAt, - ) - return err -} - -const listShortcutTokensByUser = `-- name: ListShortcutTokensByUser :many -SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked -FROM shortcut_tokens -WHERE user_id = ? -ORDER BY created_at DESC -` - -func (q *Queries) ListShortcutTokensByUser(ctx context.Context, userID string) ([]ShortcutToken, error) { - rows, err := q.db.QueryContext(ctx, listShortcutTokensByUser, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ShortcutToken - for rows.Next() { - var i ShortcutToken - if err := rows.Scan( - &i.Jti, - &i.UserID, - &i.Label, - &i.Scopes, - &i.CreatedAt, - &i.ExpiresAt, - &i.LastUsedAt, - &i.Revoked, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const revokeShortcutToken = `-- name: RevokeShortcutToken :execrows -UPDATE shortcut_tokens -SET revoked = 1 -WHERE jti = ? AND user_id = ? -` - -type RevokeShortcutTokenParams struct { - Jti string `json:"jti"` - UserID string `json:"user_id"` -} - -func (q *Queries) RevokeShortcutToken(ctx context.Context, arg RevokeShortcutTokenParams) (int64, error) { - result, err := q.db.ExecContext(ctx, revokeShortcutToken, arg.Jti, arg.UserID) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -const touchShortcutTokenUsed = `-- name: TouchShortcutTokenUsed :exec -UPDATE shortcut_tokens -SET last_used_at = ? -WHERE jti = ? -` - -type TouchShortcutTokenUsedParams struct { - LastUsedAt *int64 `json:"last_used_at"` - Jti string `json:"jti"` -} - -func (q *Queries) TouchShortcutTokenUsed(ctx context.Context, arg TouchShortcutTokenUsedParams) error { - _, err := q.db.ExecContext(ctx, touchShortcutTokenUsed, arg.LastUsedAt, arg.Jti) - return err -} diff --git a/internal/db/web_sessions.sql.go b/internal/db/web_sessions.sql.go deleted file mode 100644 index 118ce67..0000000 --- a/internal/db/web_sessions.sql.go +++ /dev/null @@ -1,286 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: web_sessions.sql - -package db - -import ( - "context" -) - -const createSelfSession = `-- name: CreateSelfSession :exec -INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, kind, scope, granted_by, created_at, last_used_at, expires_at) -VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?) -` - -type CreateSelfSessionParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` - DeviceName string `json:"device_name"` - UserAgent string `json:"user_agent"` - Kind string `json:"kind"` - Scope string `json:"scope"` - GrantedBy string `json:"granted_by"` - CreatedAt int64 `json:"created_at"` - LastUsedAt int64 `json:"last_used_at"` - ExpiresAt int64 `json:"expires_at"` -} - -// CreateSelfSession inserts a cdrop self-signed session (kind self/guest) that has -// no IdP refresh_token (stored empty): refresh mints a fresh access token straight -// from this row without touching the IdP. Used by QR scan-login (AUTH.md 4). -func (q *Queries) CreateSelfSession(ctx context.Context, arg CreateSelfSessionParams) error { - _, err := q.db.ExecContext(ctx, createSelfSession, - arg.ID, - arg.UserID, - arg.DeviceName, - arg.UserAgent, - arg.Kind, - arg.Scope, - arg.GrantedBy, - arg.CreatedAt, - arg.LastUsedAt, - arg.ExpiresAt, - ) - return err -} - -const createWebSession = `-- name: CreateWebSession :exec - -INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) -` - -type CreateWebSessionParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` - RefreshToken string `json:"refresh_token"` - DeviceName string `json:"device_name"` - UserAgent string `json:"user_agent"` - CreatedAt int64 `json:"created_at"` - LastUsedAt int64 `json:"last_used_at"` - ExpiresAt int64 `json:"expires_at"` -} - -// web_sessions: browser "passwordless re-login". The opaque cookie token is -// never stored; id holds its SHA-256, so a DB leak yields no usable cookie. -// refresh_token is AES-256-GCM ciphertext (key lives in the container env, not -// the DB). 7-day sliding window: every refresh rotates the token and pushes -// expires_at forward; idle sessions are reaped by DeleteExpiredWebSessions. -func (q *Queries) CreateWebSession(ctx context.Context, arg CreateWebSessionParams) error { - _, err := q.db.ExecContext(ctx, createWebSession, - arg.ID, - arg.UserID, - arg.RefreshToken, - arg.DeviceName, - arg.UserAgent, - arg.CreatedAt, - arg.LastUsedAt, - arg.ExpiresAt, - ) - return err -} - -const deleteExpiredWebSessions = `-- name: DeleteExpiredWebSessions :execrows -DELETE FROM web_sessions -WHERE expires_at < ? -` - -func (q *Queries) DeleteExpiredWebSessions(ctx context.Context, expiresAt int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteExpiredWebSessions, expiresAt) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -const deleteWebSession = `-- name: DeleteWebSession :exec -DELETE FROM web_sessions -WHERE id = ? -` - -func (q *Queries) DeleteWebSession(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, deleteWebSession, id) - return err -} - -const deleteWebSessionForUser = `-- name: DeleteWebSessionForUser :execrows -DELETE FROM web_sessions -WHERE id = ? AND user_id = ? -` - -type DeleteWebSessionForUserParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` -} - -// DeleteWebSessionForUser drops one session scoped to its owner, so a user can -// only revoke their own. -func (q *Queries) DeleteWebSessionForUser(ctx context.Context, arg DeleteWebSessionForUserParams) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteWebSessionForUser, arg.ID, arg.UserID) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -const getWebSession = `-- name: GetWebSession :one -SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at -FROM web_sessions -WHERE id = ? -` - -func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, error) { - row := q.db.QueryRowContext(ctx, getWebSession, id) - var i WebSession - err := row.Scan( - &i.ID, - &i.UserID, - &i.RefreshToken, - &i.DeviceName, - &i.UserAgent, - &i.CreatedAt, - &i.LastUsedAt, - &i.ExpiresAt, - &i.Kind, - &i.Scope, - &i.GrantedBy, - &i.SteppedUpAt, - ) - return i, err -} - -const listWebSessionsByUser = `-- name: ListWebSessionsByUser :many -SELECT id, user_id, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by -FROM web_sessions -WHERE user_id = ? -ORDER BY last_used_at DESC -` - -type ListWebSessionsByUserRow struct { - ID string `json:"id"` - UserID string `json:"user_id"` - DeviceName string `json:"device_name"` - UserAgent string `json:"user_agent"` - CreatedAt int64 `json:"created_at"` - LastUsedAt int64 `json:"last_used_at"` - ExpiresAt int64 `json:"expires_at"` - Kind string `json:"kind"` - Scope string `json:"scope"` - GrantedBy string `json:"granted_by"` -} - -// ListWebSessionsByUser lists a user's sessions for the management UI (revoke -// borrowed / guest devices). granted_by ties a scan-approved session to its -// approver for cascade revocation. -func (q *Queries) ListWebSessionsByUser(ctx context.Context, userID string) ([]ListWebSessionsByUserRow, error) { - rows, err := q.db.QueryContext(ctx, listWebSessionsByUser, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListWebSessionsByUserRow - for rows.Next() { - var i ListWebSessionsByUserRow - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.DeviceName, - &i.UserAgent, - &i.CreatedAt, - &i.LastUsedAt, - &i.ExpiresAt, - &i.Kind, - &i.Scope, - &i.GrantedBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const rotateWebSession = `-- name: RotateWebSession :exec -UPDATE web_sessions -SET refresh_token = ?, last_used_at = ?, expires_at = ? -WHERE id = ? -` - -type RotateWebSessionParams struct { - RefreshToken string `json:"refresh_token"` - LastUsedAt int64 `json:"last_used_at"` - ExpiresAt int64 `json:"expires_at"` - ID string `json:"id"` -} - -func (q *Queries) RotateWebSession(ctx context.Context, arg RotateWebSessionParams) error { - _, err := q.db.ExecContext(ctx, rotateWebSession, - arg.RefreshToken, - arg.LastUsedAt, - arg.ExpiresAt, - arg.ID, - ) - return err -} - -const setSessionSteppedUp = `-- name: SetSessionSteppedUp :exec -UPDATE web_sessions -SET stepped_up_at = ? -WHERE id = ? -` - -type SetSessionSteppedUpParams struct { - SteppedUpAt int64 `json:"stepped_up_at"` - ID string `json:"id"` -} - -// SetSessionSteppedUp stamps the moment this session last passed step-up re-auth; -// sensitive actions within the freshness window then skip re-auth (AUTH.md 6). -func (q *Queries) SetSessionSteppedUp(ctx context.Context, arg SetSessionSteppedUpParams) error { - _, err := q.db.ExecContext(ctx, setSessionSteppedUp, arg.SteppedUpAt, arg.ID) - return err -} - -const setWebSessionDevice = `-- name: SetWebSessionDevice :exec -UPDATE web_sessions -SET device_name = ? -WHERE id = ? -` - -type SetWebSessionDeviceParams struct { - DeviceName string `json:"device_name"` - ID string `json:"id"` -} - -func (q *Queries) SetWebSessionDevice(ctx context.Context, arg SetWebSessionDeviceParams) error { - _, err := q.db.ExecContext(ctx, setWebSessionDevice, arg.DeviceName, arg.ID) - return err -} - -const slideWebSession = `-- name: SlideWebSession :exec -UPDATE web_sessions -SET last_used_at = ?, expires_at = ? -WHERE id = ? -` - -type SlideWebSessionParams struct { - LastUsedAt int64 `json:"last_used_at"` - ExpiresAt int64 `json:"expires_at"` - ID string `json:"id"` -} - -// SlideWebSession pushes a session's sliding window forward without rotating any -// token. Self/guest sessions use it on refresh (they have no refresh_token to -// rotate); oidc sessions keep using RotateWebSession. -func (q *Queries) SlideWebSession(ctx context.Context, arg SlideWebSessionParams) error { - _, err := q.db.ExecContext(ctx, slideWebSession, arg.LastUsedAt, arg.ExpiresAt, arg.ID) - return err -} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go deleted file mode 100644 index f77ab6f..0000000 --- a/internal/httpapi/auth.go +++ /dev/null @@ -1,496 +0,0 @@ -package httpapi - -import ( - "context" - "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" - - "commilitia.net/cdrop/internal/db" -) - -// 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 browser is then handed a cdrop self-signed access token (sid-bound -// to its web_sessions row), not the IdP's RS256 token, so OIDC web sessions verify -// statefully and revoke immediately — unified with scan-login (AUTH.md §5). - -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"` -} - -// exchangeResp deliberately omits refresh_token: the durable credential never -// reaches the browser. It's encrypted into the server-side web_sessions row and -// represented to the client only by the HttpOnly session cookie set alongside. -type exchangeResp struct { - AccessToken string `json:"access_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 - } - - identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim) - if err != nil { - writeJSON(w, http.StatusBadGateway, - map[string]string{"error": "extract user: " + err.Error()}) - return - } - user := identity.User - // Refresh the thin accounts row (display name / avatar / roles / match_key). - s.upsertAccount(r.Context(), identity) - - // By default the browser holds a cdrop self-signed access token (sid-bound), - // not the IdP's RS256 token — so the OIDC web session is verified statefully on - // every request (verifySelfToken → web_sessions lookup) and revoking the row - // logs this device out on its very next call, exactly like scan-login. We mint - // it only after stashing the refresh_token server-side (encrypted, behind an - // HttpOnly cookie) so the session is durable. The IdP RS256 token stays the - // fallback for degraded configs (no encryption key / no refresh_token / an - // id_token that fails verification) — it is still independently verified per - // request via JWKS (verifyRS256), and the next refresh upgrades the device to - // the unified self-token model. (AUTH.md §5; desktop keeps its RS256 token.) - accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn - if len(s.sessionKey) > 0 && tr.RefreshToken != "" { - id, cerr := s.createWebSession(r, w, user.ID, tr.RefreshToken) - if cerr != nil { - slog.Error("create web session failed", "err", cerr) - } else { - // The id_token is the SOLE trust anchor for the minted self token (the - // IdP RS256 token no longer reaches the browser to be re-verified), so - // its signature MUST validate here and its subject must match. On - // failure we keep the durable session but hand back the per-request- - // verified RS256 token; the next refresh upgrades this device to the - // self-token model. (Should not happen with a healthy IdP — warn ops.) - sub, _, verr := s.auth.VerifyIDToken(r.Context(), tr.IDToken) - if verr != nil || sub != user.ID { - slog.Warn("oidc id_token verification failed; using RS256 fallback", - "err", verr, "sub_match", sub == user.ID) - } else if tok, ein, merr := s.mintSessionToken(user.ID, id, "full"); merr != nil { - slog.Error("mint session token failed", "err", merr) - } else { - accessToken, expiresIn = tok, ein - } - } - } - - writeJSON(w, http.StatusOK, exchangeResp{ - AccessToken: accessToken, - ExpiresIn: expiresIn, - User: user, - }) -} - -// createWebSession encrypts the refresh_token, persists a new session row, and -// sets the session cookie on the response. Returns the session row id so the -// caller can bind a cdrop self-signed access token to it (sid). -func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) (string, error) { - raw, id, err := newSessionToken() - if err != nil { - return "", err - } - enc, err := s.encryptRefresh(refreshToken) - if err != nil { - return "", err - } - now := time.Now() - if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{ - ID: id, - UserID: userID, - RefreshToken: enc, - DeviceName: "", - UserAgent: truncate(r.UserAgent(), 256), - CreatedAt: now.Unix(), - LastUsedAt: now.Unix(), - ExpiresAt: now.Add(webSessionTTL).Unix(), - }); err != nil { - return "", err - } - setSessionCookie(w, raw) - return id, nil -} - -// refreshResp is what the browser sees: a fresh access_token plus the identity -// and device name recovered from the session. No refresh_token — it stays -// server-side. This same endpoint, called with the cookie and no body on app -// boot, IS the passwordless re-login path. -type refreshResp struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - User userResp `json:"user"` - DeviceName string `json:"device_name"` -} - -func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { - if s.cfg.OIDCTokenURL == "" { - writeJSON(w, http.StatusInternalServerError, - map[string]string{"error": "OIDC token URL not configured"}) - return - } - if !s.sameOrigin(r) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) - return - } - - c, err := r.Cookie(sessionCookieName) - if err != nil || c.Value == "" { - clearSessionCookie(w) - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"}) - return - } - id := sessionID(c.Value) - - // Serialise concurrent refreshes of this session, then read the row INSIDE the - // lock: a racing tab may already have rotated the refresh_token, and we must - // spend the current one, not a stale copy the IdP would reject. - unlock := s.lockRefresh(id) - defer unlock() - - sess, err := s.queries.GetWebSession(r.Context(), id) - if err != nil { - // Unknown / deleted session → not authenticated. Clear the stale cookie. - clearSessionCookie(w) - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"}) - return - } - now := time.Now() - if sess.ExpiresAt < now.Unix() { - _ = s.queries.DeleteWebSession(r.Context(), id) - clearSessionCookie(w) - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "session expired"}) - return - } - - // Self-signed sessions (scan-login: kind self/guest) carry no IdP refresh_token. - // Mint a fresh cdrop access token straight from the row, slide the window for - // persistent sessions, re-arm the cookie — never touching the IdP (AUTH.md §3.1). - if sess.Kind != "oidc" { - s.refreshSelfSession(w, r, sess, c.Value, now) - return - } - - refreshToken, err := s.decryptRefresh(sess.RefreshToken) - if err != nil { - // Corrupt ciphertext or rotated key — the session is unusable, drop it. - _ = s.queries.DeleteWebSession(r.Context(), id) - clearSessionCookie(w) - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"}) - return - } - - form := url.Values{} - form.Set("grant_type", "refresh_token") - form.Set("refresh_token", 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 { - // The IdP rejected the refresh_token (expired / revoked) — the session is - // dead end to end, so destroy it rather than leave a row that always 401s. - slog.Warn("oidc refresh failed", "err", err) - _ = s.queries.DeleteWebSession(r.Context(), id) - clearSessionCookie(w) - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "refresh rejected"}) - return - } - - identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim) - if err != nil { - writeJSON(w, http.StatusBadGateway, - map[string]string{"error": "extract user: " + err.Error()}) - return - } - user := identity.User - // Keep the thin accounts row fresh on every successful refresh too. - s.upsertAccount(r.Context(), identity) - - // Rotate: Casdoor issues a new refresh_token on each grant; fall back to the - // existing one if it didn't. Slide the window forward and re-arm the cookie. - newRefresh := tr.RefreshToken - if newRefresh == "" { - newRefresh = refreshToken - } - if enc, encErr := s.encryptRefresh(newRefresh); encErr == nil { - if err := s.queries.RotateWebSession(r.Context(), db.RotateWebSessionParams{ - RefreshToken: enc, - LastUsedAt: now.Unix(), - ExpiresAt: now.Add(webSessionTTL).Unix(), - ID: id, - }); err != nil { - slog.Error("rotate web session failed", "err", err) - } - } else { - slog.Error("encrypt rotated refresh token failed", "err", encErr) - } - setSessionCookie(w, c.Value) - - // Hand back a cdrop self-signed access token (sid-bound), not the IdP RS256 - // token: the browser holds one uniform token type and revoking the session row - // logs this device out on its next request. The IdP round-trip above still ran - // — it rotated the refresh_token and surfaced IdP-side revocation (a rejected - // refresh deletes the session above) — its access_token simply no longer ships. - // Mint from the row's canonical user_id; degrade to the IdP token only if the - // HS256 key is unconfigured. (AUTH.md §5.) - accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn - if tok, ein, mErr := s.mintSessionToken(sess.UserID, id, "full"); mErr != nil { - slog.Error("mint session token failed", "err", mErr) - } else { - accessToken, expiresIn = tok, ein - } - - writeJSON(w, http.StatusOK, refreshResp{ - AccessToken: accessToken, - ExpiresIn: expiresIn, - User: user, - DeviceName: sess.DeviceName, - }) -} - -// refreshSelfSession renews a cdrop self-signed session without contacting the -// IdP: it mints a fresh access token from the row. Persistent "self" sessions -// slide their inactivity window; one-time "guest" borrows do not (their fixed -// expires_at caps the borrow at QR_GUEST_TTL). Identity is enriched from the -// accounts row when present, else falls back to the bare user_id. -func (s *Server) refreshSelfSession(w http.ResponseWriter, r *http.Request, sess db.WebSession, rawCookie string, now time.Time) { - token, expiresIn, err := s.mintSessionToken(sess.UserID, sess.ID, sess.Scope) - if err != nil { - slog.Error("mint session token failed", "err", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"}) - return - } - if sess.Kind == "self" { - newExp := now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour) - if err := s.queries.SlideWebSession(r.Context(), db.SlideWebSessionParams{ - LastUsedAt: now.Unix(), - ExpiresAt: newExp.Unix(), - ID: sess.ID, - }); err != nil { - slog.Error("slide web session failed", "err", err) - } - setSessionCookie(w, rawCookie) - } - user := userResp{ID: sess.UserID, Name: sess.UserID} - if acct, err := s.queries.GetAccount(r.Context(), sess.UserID); err == nil { - if acct.DisplayName != "" { - user.Name = acct.DisplayName - } - user.Avatar = acct.AvatarUrl - } - writeJSON(w, http.StatusOK, refreshResp{ - AccessToken: token, - ExpiresIn: expiresIn, - User: user, - DeviceName: sess.DeviceName, - }) -} - -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 -} - -// tokenIdentity is what cdrop reads out of an OIDC id_token at login: the display -// identity plus the thin-account fields — match_key (for the admin-gated migration -// relink) and roles (the groups claim cache). AUTH.md §2.1. -type tokenIdentity struct { - User userResp - MatchKey string - Roles []string -} - -// extractIdentity parses {sub, preferred_username | name | email, avatar | picture, -// matchClaim, groups} from the id_token if available, else the access_token. The -// signature is NOT verified here — callers verify separately around it: at login -// (handleAuthExchange) the id_token signature is checked via VerifyIDToken before -// the extracted subject is trusted to mint a self token; at refresh the IdP just -// authenticated the refresh_token over a TLS back-channel, so the token it returns -// is trusted by transport. Either way the extracted fields are sound. (AUTH.md §5.) -func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, 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 tokenIdentity{}, err - } - var std jwt.Claims - custom := map[string]any{} - if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil { - return tokenIdentity{}, err - } - id := tokenIdentity{User: userResp{ID: std.Subject}} - if v, ok := custom["preferred_username"].(string); ok && v != "" { - id.User.Name = v - } else if v, ok := custom["name"].(string); ok && v != "" { - id.User.Name = v - } else if v, ok := custom["email"].(string); ok && v != "" { - id.User.Name = v - } else { - id.User.Name = id.User.ID - } - // Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段; - // 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。 - if v, ok := custom["avatar"].(string); ok && v != "" { - id.User.Avatar = v - } else if v, ok := custom["picture"].(string); ok && v != "" { - id.User.Avatar = v - } - // match_key feeds the admin-gated cross-provider relink; pick the configured - // claim (default email). Absent / non-string → empty, which simply never matches. - if matchClaim != "" { - if v, ok := custom[matchClaim].(string); ok { - id.MatchKey = v - } - } - if g, ok := custom["groups"].([]any); ok { - for _, item := range g { - if r, ok := item.(string); ok && r != "" { - id.Roles = append(id.Roles, r) - } - } - } - return id, nil -} - -// upsertAccount refreshes the caller's thin accounts row from their OIDC identity -// (AUTH.md §2.1). Best-effort — a write failure must never fail the login itself. -func (s *Server) upsertAccount(ctx context.Context, id tokenIdentity) { - if id.User.ID == "" { - return - } - now := time.Now().Unix() - if err := s.queries.UpsertAccount(ctx, db.UpsertAccountParams{ - UserID: id.User.ID, - MatchKey: id.MatchKey, - DisplayName: id.User.Name, - AvatarUrl: id.User.Avatar, - Roles: strings.Join(id.Roles, ","), - Provider: "oidc", - CreatedAt: now, - LastLoginAt: now, - }); err != nil { - slog.Error("upsert account failed", "err", err, "user", id.User.ID) - } -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go deleted file mode 100644 index 2469336..0000000 --- a/internal/httpapi/auth_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package httpapi - -import ( - "testing" - "time" - - "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" -) - -// extractIdentity pulls the display identity plus the thin-account fields -// (match_key from the configured claim, roles from groups) out of an id_token. -// It does not verify the signature, so a throwaway HS256 key suffices here. -func TestExtractIdentity(t *testing.T) { - key := []byte("any-throwaway-signing-key-32-bytes!!") - sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT")) - if err != nil { - t.Fatalf("signer: %v", err) - } - tok, err := jwt.Signed(sig). - Claims(jwt.Claims{Subject: "sub-123", Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour))}). - Claims(map[string]any{ - "preferred_username": "alice", - "email": "alice@example.net", - "avatar": "https://a/av.png", - "groups": []any{"admins", "staff"}, - }).Serialize() - if err != nil { - t.Fatalf("sign: %v", err) - } - - id, err := extractIdentity(tok, "", "email") - if err != nil { - t.Fatalf("extract: %v", err) - } - if id.User.ID != "sub-123" || id.User.Name != "alice" || id.User.Avatar != "https://a/av.png" { - t.Errorf("user wrong: %+v", id.User) - } - if id.MatchKey != "alice@example.net" { - t.Errorf("match_key: got %q, want alice@example.net", id.MatchKey) - } - if len(id.Roles) != 2 || id.Roles[0] != "admins" || id.Roles[1] != "staff" { - t.Errorf("roles: got %v, want [admins staff]", id.Roles) - } - - // No name claim → falls back to sub; absent match claim → empty (never matches). - tok2, _ := jwt.Signed(sig). - Claims(jwt.Claims{Subject: "sub-x", Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour))}). - Serialize() - id2, err := extractIdentity(tok2, "", "email") - if err != nil { - t.Fatalf("extract2: %v", err) - } - if id2.User.Name != "sub-x" || id2.MatchKey != "" || len(id2.Roles) != 0 { - t.Errorf("fallback wrong: %+v", id2) - } -} diff --git a/internal/httpapi/device_session.go b/internal/httpapi/device_session.go new file mode 100644 index 0000000..dc829cf --- /dev/null +++ b/internal/httpapi/device_session.go @@ -0,0 +1,158 @@ +package httpapi + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" + + "commilitia.net/cdrop/internal/brokerclient" + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/jwtauth" +) + +// 代铸 (proxy-mint). The unified-session-model endpoint that makes every login — browser +// global-SSO and native device-authorize alike — a cdrop-managed device session, so all +// clients share one device list and one management surface. +// +// The caller has already been verified at the edge (X-Auth-Subject) by either a global-SSO +// cookie (browser) or a device-authorize bootstrap token (desktop/native). cdrop vouches for +// that subject and asks the broker (R2: idempotent by user+app+meta) to mint or rotate a +// device session bound to the client's stable device_id. Re-logins with the same device_id +// rotate the same session instead of piling up — the same trust model as scan-login collect. + +type deviceSessionReq struct { + // DeviceID is the client's stable opaque id (persisted client-side). Empty on first + // contact — the server then mints one and returns it for the client to persist. + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + DeviceType string `json:"device_type"` // browser | macos | windows | linux | ios +} + +type deviceSessionResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + // UserID + Name + Avatar are the verified identity (X-Auth-Subject / X-Auth-Name / + // X-Auth-Avatar). They let a native client — which only ever sees nameless machine + // tokens — populate its identity with the real display name and picture instead of the + // subject UUID, without a /api/me round-trip. + UserID string `json:"user_id"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` +} + +// handleDeviceSession mints (or idempotently rotates) the caller's cdrop device session. +func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) { + // CSRF: a cookie-authenticated browser fetch carries a matching Origin; a cross-site + // forgery would not (blocking forged mints that would reintroduce phantom devices). The + // desktop's Go-initiated call sends no Origin and is allowed. + if !s.sameOrigin(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) + return + } + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + + var req deviceSessionReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + + deviceID := strings.TrimSpace(req.DeviceID) + if deviceID == "" { + var err error + if deviceID, err = newDeviceID(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) + return + } + } else if !validDeviceID(deviceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid device_id"}) + return + } + + deviceName := jwtauth.SanitizeDeviceName(req.DeviceName) + if deviceName == "" { + deviceName = "New device" + } + deviceType := qrDeviceType(req.DeviceType) + + // Mint at the caller's current trust tier: an SSO / device-authorize login is full, a + // restricted guest stays guest. This stops a borrowed (guest) browser from minting + // itself a full device session. + tier := "full" + if claims.Guest() { + tier = "guest" + } + accessTTL, refreshTTL := s.tierTTLs(tier) + + sess, err := s.broker.MintSession(r.Context(), brokerclient.MintParams{ + UserID: claims.UserID, + Tier: tier, + AccessTTL: accessTTL, + RefreshTTL: refreshTTL, + Sliding: true, + Label: deviceName, + Meta: deviceID, + }) + if err != nil { + slog.Error("device-session mint failed", "err", err, "user", claims.UserID) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "mint failed"}) + return + } + + now := time.Now().Unix() + if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{ + DeviceID: deviceID, + UserID: claims.UserID, + Name: deviceName, + Type: deviceType, + Tier: tier, + BrokerSid: sess.SID, + CreatedAt: now, + LastSeen: now, + }); err != nil { + // The session is minted and usable; a failed cache-row write only costs the local + // type/presence overlay, so proceed rather than strand the device without tokens. + slog.Error("device-session cache write failed", "err", err, "user", claims.UserID, "device", deviceID) + } + + name := claims.Name + if name == "" { + name = claims.UserID + } + expiresIn := int(sess.AccessExpires - now) + if expiresIn < 0 { + expiresIn = 0 + } + writeJSON(w, http.StatusOK, deviceSessionResp{ + AccessToken: sess.Access, + RefreshToken: sess.Refresh, + ExpiresIn: expiresIn, + DeviceID: deviceID, + DeviceName: deviceName, + UserID: claims.UserID, + Name: name, + Avatar: claims.Avatar, + }) +} + +// validDeviceID accepts a cdrop device_id: the "dev_" prefix plus pure [A-Za-z0-9_-], capped +// in length. This both recognises cdrop's own ids (newDeviceID) and guarantees the value is +// control-byte-free, so it is safe to pass to the broker as meta (echoed into X-Auth-Meta). +func validDeviceID(id string) bool { + if !strings.HasPrefix(id, "dev_") || len(id) > 128 { + return false + } + for _, c := range id { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-': + default: + return false + } + } + return true +} diff --git a/internal/httpapi/devices.go b/internal/httpapi/devices.go index 4aca861..33587b5 100644 --- a/internal/httpapi/devices.go +++ b/internal/httpapi/devices.go @@ -1,6 +1,7 @@ package httpapi import ( + "encoding/json" "log/slog" "net/http" @@ -11,8 +12,10 @@ import ( ) type deviceItem struct { + DeviceID string `json:"device_id"` Name string `json:"name"` Type string `json:"type"` + Tier string `json:"tier"` Online bool `json:"online"` LastSeen int64 `json:"last_seen"` } @@ -32,8 +35,10 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { out := make([]deviceItem, 0, len(devs)) for _, d := range devs { out = append(out, deviceItem{ + DeviceID: d.DeviceID, Name: d.Name, Type: d.Type, + Tier: d.Tier, Online: s.hub.Online(claims.UserID, d.Name), LastSeen: d.LastSeen, }) @@ -41,40 +46,64 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"devices": out}) } -// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播 -// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则 -// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。 -// -// 这是登录会话列表里「原生客户端(桌面 / iOS)」一栏的登出路径——它们用 IdP 令牌、 -// 不入 web_sessions,故按设备名吊销(AUTH.md §4)。注销设备属敏感动作,与吊销网页 -// 会话同口径要求最近一次 step-up(403 step_up_required,前端据此发起再认证)。 +// handleDeleteDevice removes a device: it revokes the device's broker session (so it +// can no longer refresh and dies within its short access TTL), then drops the local +// row, kicks its live SSE, and re-broadcasts presence. Identified by the stable +// device_id; the caller must own it. A full session is required (route-gated) — the +// old step-up requirement is gone, since a full-tier session is itself trusted. func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { - if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + deviceID := chi.URLParam(r, "device_id") + if deviceID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device id"}) return } + status, ok := s.revokeDevice(r, claims.UserID, deviceID) + if !ok { + writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type renameDeviceReq struct { + Name string `json:"name"` +} + +// handleRenameDevice changes a device's human-readable name. The device's identity is +// device_id, so a rename never changes which session / row it is — fixing the old +// model where the name was the key and renaming meant losing the device. Full session. +func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) - name := chi.URLParam(r, "name") + deviceID := chi.URLParam(r, "device_id") + if deviceID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device id"}) + return + } + var body renameDeviceReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + name := jwtauth.SanitizeDeviceName(body.Name) if name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device name"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty device name"}) return } - rows, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ - UserID: claims.UserID, - Name: name, + n, err := s.queries.RenameDevice(r.Context(), db.RenameDeviceParams{ + Name: name, + DeviceID: deviceID, + UserID: claims.UserID, }) if err != nil { - slog.Error("delete device failed", "err", err, "user", claims.UserID, "name", name) + slog.Error("rename device failed", "err", err, "user", claims.UserID, "device", deviceID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } - if rows == 0 { + if n == 0 { writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"}) return } - - s.hub.Kick(claims.UserID, name) - s.hub.PublishPresence(r.Context(), claims.UserID) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/httpapi/login.go b/internal/httpapi/login.go new file mode 100644 index 0000000..241f5ff --- /dev/null +++ b/internal/httpapi/login.go @@ -0,0 +1,59 @@ +package httpapi + +import ( + "net/http" + "net/url" + "strings" +) + +type authConfigResp struct { + AuthMode string `json:"auth_mode"` + BrokerURL string `json:"broker_url"` + BrokerApp string `json:"broker_app"` +} + +// handleAuthConfig publishes the Auth Broker coordinates native clients (the desktop) +// need to run the broker device-authorization flow: the broker's public URL + this +// app's key in the broker apps registry. Public (it bootstraps native login). +func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, authConfigResp{ + AuthMode: s.cfg.AuthMode, + BrokerURL: s.cfg.BrokerPublicURL, + BrokerApp: s.cfg.BrokerAppOrDefault(), + }) +} + +// handleAuthLogin 302-redirects the browser to the Auth Broker's public login page +// for global SSO (the broker handles Casdoor and sets its domain cookie; on return the +// edge injects X-Auth from that cookie). cdrop owns this redirect so the broker's +// public URL lives in one server-side config. Public (it bootstraps login). The rd +// (return target) is constrained to this deployment's own origin — an open-redirect +// guard — defaulting to the site root. +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + if s.cfg.BrokerPublicURL == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "login redirect not configured"}) + return + } + rd := s.safeReturnTarget(r.URL.Query().Get("rd")) + dest := strings.TrimRight(s.cfg.BrokerPublicURL, "/") + "/login?rd=" + url.QueryEscape(rd) + http.Redirect(w, r, dest, http.StatusFound) +} + +// safeReturnTarget validates a post-login return URL against this deployment's origin, +// falling back to the site root. It blocks open redirects: only a same-origin absolute +// URL (or, when no site origin is configured in dev, a site-relative path) is accepted. +func (s *Server) safeReturnTarget(rd string) string { + if s.siteOrigin == "" { + // dev: accept only a site-relative path, never an absolute URL. + if strings.HasPrefix(rd, "/") && !strings.HasPrefix(rd, "//") { + return rd + } + return "/" + } + if u, err := url.Parse(rd); err == nil && u.Scheme != "" && u.Host != "" { + if u.Scheme+"://"+u.Host == s.siteOrigin { + return rd + } + } + return s.siteOrigin + "/" +} diff --git a/internal/httpapi/qr.go b/internal/httpapi/qr.go index 36b2fa0..3fff305 100644 --- a/internal/httpapi/qr.go +++ b/internal/httpapi/qr.go @@ -9,10 +9,10 @@ import ( "log/slog" "net" "net/http" - "net/url" "strings" "time" + "commilitia.net/cdrop/internal/brokerclient" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) @@ -49,12 +49,23 @@ type qrStartResp struct { ExpiresAt int64 `json:"expires_at"` } +// userResp is the display identity handed to a client. After the broker migration +// cdrop no longer holds an accounts table; the approver's name is best-effort (the new +// device refreshes it from X-Auth-Name via /api/me once its token is live). +type userResp struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` +} + type qrStatusResp struct { - Status string `json:"status"` // pending | approved | denied | expired - AccessToken string `json:"access_token,omitempty"` - ExpiresIn int `json:"expires_in,omitempty"` - User *userResp `json:"user,omitempty"` - DeviceName string `json:"device_name,omitempty"` + Status string `json:"status"` // pending | approved | denied | expired + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + User *userResp `json:"user,omitempty"` + DeviceName string `json:"device_name,omitempty"` + DeviceID string `json:"device_id,omitempty"` } type qrRequestResp struct { @@ -63,19 +74,13 @@ type qrRequestResp struct { RequestIP string `json:"request_ip"` ExpiresAt int64 `json:"expires_at"` Status string `json:"status"` - // StepUp tells the approver UI a fresh re-auth is required before approving. - StepUp bool `json:"step_up"` } type qrApproveReq struct { RequestID string `json:"request_id"` ApprovalCode string `json:"approval_code"` - Scope string `json:"scope"` // full | guest (this milestone always guest) + Scope string `json:"scope"` // full | guest Persist string `json:"persist"` // once | persist - // Step-up (when CDROP_STEP_UP_ENABLED): a fresh prompt=login PKCE code+verifier - // the backend exchanges and checks for a recent auth_time (AUTH.md §6). - StepUpCode string `json:"step_up_code"` - StepUpVerifier string `json:"step_up_verifier"` } type qrDenyReq struct { @@ -191,10 +196,13 @@ func (s *Server) handleQRStatus(w http.ResponseWriter, r *http.Request) { } } -// collectQRSession turns an approved request into a live session for the new -// device. ConsumeLoginRequest is the single-use guard: only the poll that flips -// approved→consumed proceeds, so a duplicated/raced poll can't mint a second -// session. +// collectQRSession turns an approved request into a live session for the new device. +// ConsumeLoginRequest is the single-use guard: only the poll that flips +// approved→consumed proceeds, so a duplicated/raced poll can't mint a second session. +// The session itself is minted by the Auth Broker (path A): cdrop generates a stable +// device_id, has the broker mint a scoped access+refresh bound to it, and records the +// device row (with the broker's sid for later revocation). The new device holds the +// broker tokens directly — no cdrop cookie or self-signed token. func (s *Server) collectQRSession(w http.ResponseWriter, r *http.Request, req db.LoginRequest) { n, err := s.queries.ConsumeLoginRequest(r.Context(), req.ID) if err != nil { @@ -208,63 +216,74 @@ func (s *Server) collectQRSession(w http.ResponseWriter, r *http.Request, req db return } - raw, id, err := newSessionToken() + tier := "guest" + if req.GrantScope == "full" { + tier = "full" + } + accessTTL, refreshTTL := s.tierTTLs(tier) + + deviceID, err := newDeviceID() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) return } - scope := req.GrantScope - if scope != "full" { - scope = "guest" - } - // Persistent borrows slide their window (kind self); one-time borrows are kind - // guest with a fixed short expiry that refresh never extends (AUTH.md §3.1). - now := time.Now() - kind := "guest" - exp := now.Add(time.Duration(s.cfg.QRGuestTTLSeconds) * time.Second) - if req.GrantPersist == "persist" { - kind = "self" - exp = now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour) - } - if err := s.queries.CreateSelfSession(r.Context(), db.CreateSelfSessionParams{ - ID: id, + + sess, err := s.broker.MintSession(r.Context(), brokerclient.MintParams{ UserID: req.ApproverUserID, - DeviceName: req.NewDeviceName, - UserAgent: req.UserAgent, - Kind: kind, - Scope: scope, - GrantedBy: "qr", - CreatedAt: now.Unix(), - LastUsedAt: now.Unix(), - ExpiresAt: exp.Unix(), - }); err != nil { - slog.Error("create self session failed", "err", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) - return - } - - token, expiresIn, err := s.mintSessionToken(req.ApproverUserID, id, scope) - if err != nil { - slog.Error("mint session token failed", "err", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"}) - return - } - setSessionCookie(w, raw) - - user := userResp{ID: req.ApproverUserID, Name: req.ApproverUserID} - if acct, err := s.queries.GetAccount(r.Context(), req.ApproverUserID); err == nil { - if acct.DisplayName != "" { - user.Name = acct.DisplayName - } - user.Avatar = acct.AvatarUrl - } - writeJSON(w, http.StatusOK, qrStatusResp{ - Status: "approved", - AccessToken: token, - ExpiresIn: expiresIn, - User: &user, - DeviceName: req.NewDeviceName, + Tier: tier, + AccessTTL: accessTTL, + RefreshTTL: refreshTTL, + Sliding: true, + Label: req.NewDeviceName, + Meta: deviceID, }) + if err != nil { + slog.Error("broker mint failed", "err", err, "user", req.ApproverUserID) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "mint failed"}) + return + } + + now := time.Now().Unix() + if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{ + DeviceID: deviceID, + UserID: req.ApproverUserID, + Name: req.NewDeviceName, + Type: req.NewDeviceType, + Tier: tier, + BrokerSid: sess.SID, + CreatedAt: now, + LastSeen: now, + }); err != nil { + // The session is already minted and usable; a failed device-row write only + // costs local management state (revoke / list), so proceed rather than strand + // the new device without its tokens. + slog.Error("create device failed", "err", err, "user", req.ApproverUserID, "device", deviceID) + } + + expiresIn := int(sess.AccessExpires - now) + if expiresIn < 0 { + expiresIn = 0 + } + // Best-effort display identity; the new device refreshes its real name from + // X-Auth-Name via /api/me once its token is live at the edge. + user := userResp{ID: req.ApproverUserID, Name: req.ApproverUserID} + writeJSON(w, http.StatusOK, qrStatusResp{ + Status: "approved", + AccessToken: sess.Access, + RefreshToken: sess.Refresh, + ExpiresIn: expiresIn, + User: &user, + DeviceName: req.NewDeviceName, + DeviceID: deviceID, + }) +} + +// tierTTLs returns the configured access + refresh TTLs (seconds) for a tier. +func (s *Server) tierTTLs(tier string) (accessTTL, refreshTTL int) { + if tier == "guest" { + return s.cfg.GuestAccessTTLSeconds, s.cfg.GuestRefreshTTLSeconds + } + return s.cfg.FullAccessTTLSeconds, s.cfg.FullRefreshTTLSeconds } // handleQRRequest (full session) lets the approver see what they are about to @@ -285,9 +304,6 @@ func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) { RequestIP: req.RequestIp, ExpiresAt: req.ExpiresAt, Status: req.Status, - // Tell the approver UI to re-auth only if step-up is on AND this session - // hasn't recently stepped up (within the window) — no repeated prompts (#3). - StepUp: s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r), }) } @@ -309,19 +325,10 @@ func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) { } claims, _ := jwtauth.ClaimsFromContext(r.Context()) - // Step-up: approving a new device into your account is sensitive. When enabled, - // require a fresh prompt=login re-auth (the provider's 2FA, if configured, is - // enforced during that exchange) — UNLESS this session already stepped up within - // the freshness window, so the same session isn't re-prompted repeatedly (#3). - if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) { - if !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) - return - } - s.recordStepUp(r) - } - - scope := "guest" // this milestone grants restricted guest sessions only + // Step-up (a fresh re-auth before approving a device) is deferred to the broker + // post-migration (/login?switch=1); a full-tier session is trusted to approve. + // The route is already gated by requireFullSession, so a guest can't reach here. + scope := "guest" if body.Scope == "full" { scope = "full" } @@ -329,6 +336,13 @@ func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) { if body.Persist == "persist" { persist = "persist" } + // 原生客户端(非浏览器:iOS / macOS / Windows / Linux)只接受完整权限会话——用户原则: + // 原生 App 不允许受限访客(受限访客仅是 Web / PWA 的权宜)。批准端只给「信任并继续 / 拒绝」, + // 后端在此再兜底强制 full + persist,无论批准请求送来什么 scope。 + if req.NewDeviceType != "" && req.NewDeviceType != "browser" { + scope = "full" + persist = "persist" + } now := time.Now() n, err := s.queries.ApproveLoginRequest(r.Context(), db.ApproveLoginRequestParams{ ApproverUserID: claims.UserID, @@ -373,99 +387,6 @@ func (s *Server) handleQRDeny(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// sessionFromCookie resolves the caller's web_session row from the cdrop_session -// cookie. The scan-approval endpoints live under /api/auth/*, which the cookie's -// Path covers, so both OIDC and self sessions can be located here. -func (s *Server) sessionFromCookie(r *http.Request) (db.WebSession, bool) { - c, err := r.Cookie(sessionCookieName) - if err != nil || c.Value == "" { - return db.WebSession{}, false - } - sess, err := s.queries.GetWebSession(r.Context(), sessionID(c.Value)) - if err != nil { - return db.WebSession{}, false - } - return sess, true -} - -// sessionRecentlySteppedUp reports whether the caller's session passed step-up -// within StepUpMaxAgeSeconds — so a sensitive action skips re-auth and the same -// session isn't re-prompted repeatedly (#3, AUTH.md §6). -func (s *Server) sessionRecentlySteppedUp(r *http.Request) bool { - sess, ok := s.sessionFromCookie(r) - if !ok || sess.SteppedUpAt == 0 { - return false - } - window := int64(s.cfg.StepUpMaxAgeSeconds) - if window <= 0 { - window = 300 - } - return time.Now().Unix()-sess.SteppedUpAt <= window -} - -// recordStepUp stamps the caller's session as freshly stepped-up. -func (s *Server) recordStepUp(r *http.Request) { - sess, ok := s.sessionFromCookie(r) - if !ok { - return - } - if err := s.queries.SetSessionSteppedUp(r.Context(), db.SetSessionSteppedUpParams{ - SteppedUpAt: time.Now().Unix(), - ID: sess.ID, - }); err != nil { - slog.Warn("record step-up failed", "err", err) - } -} - -// verifyStepUp exchanges a fresh prompt=login authorization code and confirms it -// proves a recent interactive re-authentication by the same user: the id_token's -// signature validates (JWKS), its sub matches the caller, and its auth_time is -// within StepUpMaxAgeSeconds. Any failure → false (the caller answers 403). The -// code is single-use at the IdP, so it can't be replayed (AUTH.md §6). -func (s *Server) verifyStepUp(r *http.Request, userID, code, verifier string) bool { - if code == "" || verifier == "" || s.cfg.OIDCTokenURL == "" { - return false - } - form := url.Values{} - form.Set("grant_type", "authorization_code") - form.Set("code", code) - form.Set("code_verifier", verifier) - form.Set("client_id", s.cfg.OIDCClientID) - form.Set("redirect_uri", s.cfg.OIDCRedirectURI) - 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("step-up exchange failed", "err", err) - return false - } - if tr.IDToken == "" { - return false - } - sub, authTime, err := s.auth.VerifyIDToken(r.Context(), tr.IDToken) - if err != nil { - slog.Warn("step-up id_token invalid", "err", err) - return false - } - if sub != userID { - return false - } - // Enforce the freshness window only when the IdP supplied auth_time (Casdoor - // omits it). When absent, the fresh single-use prompt=login code — exchanged - // once, just now — is itself the bound on how recent the re-auth was. - if authTime > 0 { - maxAge := int64(s.cfg.StepUpMaxAgeSeconds) - if maxAge <= 0 { - maxAge = 300 - } - if time.Now().Unix()-authTime > maxAge { - return false - } - } - return true -} - // lookupQRRequest fetches a request and verifies the approval_code in constant // time. It writes the error response itself and returns ok=false on any failure // (missing args, unknown request, bad code, expired), so callers just early-return. @@ -530,3 +451,14 @@ func randB64(n int) (string, error) { } return base64.RawURLEncoding.EncodeToString(b), nil } + +// newDeviceID mints a stable opaque device identifier ("dev_" + base64url(16 random +// bytes)). It is cdrop's session<->device join key: passed to the broker as meta and +// echoed back as X-Auth-Meta. Pure [A-Za-z0-9_-], no control bytes (validMeta-safe). +func newDeviceID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "dev_" + base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/httpapi/qr_test.go b/internal/httpapi/qr_test.go index d799803..84b5fa1 100644 --- a/internal/httpapi/qr_test.go +++ b/internal/httpapi/qr_test.go @@ -13,21 +13,115 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" + "commilitia.net/cdrop/internal/brokerclient" "commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/hub" "commilitia.net/cdrop/internal/jwtauth" ) -const qrTestSecret = "qr-test-session-secret-at-least-32-bytes" +// mockBrokerState records what the fake Auth Broker was asked to do, so tests can +// assert cdrop delegated correctly (mint params, revoke + its X-Broker-App scope). +type mockBrokerState struct { + mintCount int + lastMint map[string]any + revoked map[string]bool + lastRevokeApp string + // sessions holds the live (non-revoked) delegated sessions keyed by sid, so the mock + // can answer R1 (GET /internal/sessions) and enforce R2 idempotency (same user+app+meta + // → same sid). + sessions map[string]*mockSession +} -// newQRTestServer builds a Server with only the fields the scan-login handlers -// touch, backed by a fresh file-based sqlite (an in-memory DB would give each -// pooled connection its own empty schema). -func newQRTestServer(t *testing.T) *Server { +type mockSession struct { + sid, userID, app, meta, label, scope string + createdAt, lastUsedAt int64 +} + +// newMockBroker stands in for the Auth Broker's internal API: POST /internal/sessions +// mints (R2-idempotent by user+app+meta) a session, GET /internal/sessions lists the +// user's live machine sessions (R1), and DELETE /internal/sessions/{sid} revokes one. +func newMockBroker(t *testing.T) (*brokerclient.Client, *mockBrokerState) { + t.Helper() + st := &mockBrokerState{revoked: map[string]bool{}, sessions: map[string]*mockSession{}} + str := func(m map[string]any, k string) string { + if v, ok := m[k].(string); ok { + return v + } + return "" + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/internal/sessions": + st.mintCount += 1 + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + st.lastMint = body + userID, app, meta, label, tier := str(body, "user_id"), str(body, "app"), str(body, "meta"), str(body, "label"), str(body, "tier") + scope := "app:" + app + if tier != "" { + scope += ":" + tier + } + // R2 idempotency: same (user, app, meta) with non-empty meta rotates in place. + sid := "" + if meta != "" { + for _, sess := range st.sessions { + if !st.revoked[sess.sid] && sess.userID == userID && sess.app == app && sess.meta == meta { + sid = sess.sid + break + } + } + } + if sid == "" { + sid = fmt.Sprintf("sid-%d", st.mintCount) + } + now := time.Now().Unix() + created := now + if existing, ok := st.sessions[sid]; ok { + created = existing.createdAt // rotation preserves CreatedAt + } + st.sessions[sid] = &mockSession{sid: sid, userID: userID, app: app, meta: meta, label: label, scope: scope, createdAt: created, lastUsedAt: now} + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": sid, "app": "cdrop", + "access": "acc-" + sid, "refresh": "rtk-" + sid, + "access_expires": time.Now().Add(15 * time.Minute).Unix(), + "refresh_expires": time.Now().Add(24 * time.Hour).Unix(), + }) + case r.Method == http.MethodGet && r.URL.Path == "/internal/sessions": + q := r.URL.Query() + userID, app := q.Get("user_id"), q.Get("app") + if userID == "" || app == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + out := []map[string]any{} + for _, sess := range st.sessions { + if st.revoked[sess.sid] || sess.userID != userID || sess.app != app { + continue + } + out = append(out, map[string]any{ + "id": sess.sid, "scope": sess.scope, "label": sess.label, "meta": sess.meta, + "created_at": sess.createdAt, "last_used_at": sess.lastUsedAt, + "expires_at": time.Now().Add(24 * time.Hour).Unix(), + }) + } + _ = json.NewEncoder(w).Encode(map[string]any{"sessions": out}) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/internal/sessions/"): + st.revoked[strings.TrimPrefix(r.URL.Path, "/internal/sessions/")] = true + st.lastRevokeApp = r.Header.Get("X-Broker-App") + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return brokerclient.New(srv.URL, "test-key", "cdrop"), st +} + +// newQRTestServer builds a Server with only the fields the scan-login + device +// handlers touch, backed by a fresh file-based sqlite and a mock broker. +func newQRTestServer(t *testing.T) (*Server, *mockBrokerState) { t.Helper() conn, err := db.Open(filepath.Join(t.TempDir(), "qr.db")) if err != nil { @@ -38,19 +132,24 @@ func newQRTestServer(t *testing.T) *Server { t.Fatalf("bootstrap: %v", err) } q := db.New(conn) - return &Server{ + broker, st := newMockBroker(t) + s := &Server{ cfg: &config.Config{ - AuthMode: "prod", SessionSecret: qrTestSecret, - QRLoginEnabled: true, QRRequestTTLSeconds: 120, - QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168, - SessionTokenTTLSeconds: 900, + AuthMode: "prod", + QRLoginEnabled: true, + QRRequestTTLSeconds: 120, + FullAccessTTLSeconds: 900, + FullRefreshTTLSeconds: 604800, + GuestAccessTTLSeconds: 900, + GuestRefreshTTLSeconds: 86400, + BrokerBaseURL: "http://broker", // non-empty so QRLoginOn() is true }, - queries: q, - hub: hub.New(q), - sessionKey: deriveSessionKey(qrTestSecret), - sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret), - siteOrigin: "https://drop.example.net", + queries: q, + hub: hub.New(q), + broker: broker, + siteOrigin: "https://drop.example.net", } + return s, st } func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) { @@ -73,11 +172,11 @@ func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) { return resp, u.Query().Get("c") } -func qrApprove(t *testing.T, s *Server, requestID, code, persist, approver string) int { +func qrApprove(t *testing.T, s *Server, requestID, code, scope, persist, approver string) int { t.Helper() - body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":"guest","persist":%q}`, requestID, code, persist) + body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":%q,"persist":%q}`, requestID, code, scope, persist) r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", strings.NewReader(body)) - r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, SessionScope: "full"})) + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, Scope: "full"})) w := httptest.NewRecorder() s.handleQRApprove(w, r) return w.Code @@ -100,14 +199,15 @@ func decodeStatus(t *testing.T, w *httptest.ResponseRecorder) qrStatusResp { return resp } -// The happy path: a new device starts a request, the approver authorises it as a -// one-time guest, and the new device collects a live guest session — cookie set, -// access token minted, request single-use thereafter. +// Happy path: a new device starts a request, the approver authorises it as a guest, +// and the new device collects a live guest session minted by the broker — broker +// access + refresh handed back, a device row recorded with the broker sid, the mint +// scoped to the device_id, and the request single-use thereafter. func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) { - s := newQRTestServer(t) + s, st := newQRTestServer(t) start, code := qrStart(t, s, "Borrowed Laptop") - if c := qrApprove(t, s, start.RequestID, code, "once", "approver-1"); c != http.StatusNoContent { + if c := qrApprove(t, s, start.RequestID, code, "guest", "once", "approver-1"); c != http.StatusNoContent { t.Fatalf("approve: got %d, want 204", c) } @@ -116,34 +216,31 @@ func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) { t.Fatalf("status: %d %s", w.Code, w.Body.String()) } got := decodeStatus(t, w) - if got.Status != "approved" || got.AccessToken == "" || got.DeviceName != "Borrowed Laptop" { + if got.Status != "approved" || got.AccessToken == "" || got.RefreshToken == "" { t.Fatalf("collected status wrong: %+v", got) } - if got.ExpiresIn != 900 { - t.Errorf("expires_in: got %d, want 900", got.ExpiresIn) + if got.DeviceName != "Borrowed Laptop" || got.DeviceID == "" { + t.Fatalf("collected device wrong: %+v", got) } - var hasCookie bool - for _, ck := range w.Result().Cookies() { - if ck.Name == sessionCookieName && ck.Value != "" { - hasCookie = true - } - } - if !hasCookie { - t.Error("collection must set the session cookie on the new device's response") + if !strings.HasPrefix(got.DeviceID, "dev_") { + t.Errorf("device_id not opaque dev_ token: %q", got.DeviceID) } - // The minted token is a properly signed guest session token. - if scope := selfTokenScope(t, got.AccessToken); scope != "guest" { - t.Fatalf("minted token scope: got %q, want guest", scope) + // The broker was asked to mint at tier guest with our device_id as meta. + if st.mintCount != 1 { + t.Fatalf("mint count: got %d, want 1", st.mintCount) + } + if st.lastMint["tier"] != "guest" || st.lastMint["meta"] != got.DeviceID { + t.Errorf("mint params wrong: %+v", st.lastMint) } - // A guest (one-time) session row exists for the approver and is non-sliding. - rows, err := s.queries.ListWebSessionsByUser(context.Background(), "approver-1") - if err != nil || len(rows) != 1 { - t.Fatalf("expected one session row, got %d (err=%v)", len(rows), err) + // A device row exists for the approver, tier guest, bound to the broker sid. + dev, err := s.queries.GetDevice(context.Background(), got.DeviceID) + if err != nil { + t.Fatalf("device row missing: %v", err) } - if rows[0].Kind != "guest" || rows[0].Scope != "guest" { - t.Errorf("session kind/scope: got %s/%s, want guest/guest", rows[0].Kind, rows[0].Scope) + if dev.UserID != "approver-1" || dev.Tier != "guest" || dev.BrokerSid != "sid-1" { + t.Errorf("device row wrong: %+v", dev) } // Single use: a second collection finds the request consumed. @@ -152,288 +249,131 @@ func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) { } } -// A persistent ("trust this device") approval yields a sliding self session. -func TestQRFlow_PersistYieldsSelfSession(t *testing.T) { - s := newQRTestServer(t) +// A full approval mints a full-tier session. +func TestQRFlow_ApproveCollectFull(t *testing.T) { + s, st := newQRTestServer(t) start, code := qrStart(t, s, "Home PC") - if c := qrApprove(t, s, start.RequestID, code, "persist", "approver-2"); c != http.StatusNoContent { + if c := qrApprove(t, s, start.RequestID, code, "full", "persist", "approver-2"); c != http.StatusNoContent { t.Fatalf("approve: got %d, want 204", c) } - if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" { - t.Fatalf("collect: %+v", got) + got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)) + if got.Status != "approved" { + t.Fatalf("status: %+v", got) } - rows, _ := s.queries.ListWebSessionsByUser(context.Background(), "approver-2") - if len(rows) != 1 || rows[0].Kind != "self" { - t.Fatalf("persistent approval must create a kind=self session, got %+v", rows) + if st.lastMint["tier"] != "full" { + t.Errorf("mint tier: got %v, want full", st.lastMint["tier"]) + } + dev, err := s.queries.GetDevice(context.Background(), got.DeviceID) + if err != nil || dev.Tier != "full" { + t.Errorf("device row: %+v (err=%v)", dev, err) } } -// The poll_secret is the new device's only credential: a wrong one is rejected -// even for a real, approved request, so a QR photographer can't collect it. func TestQRFlow_WrongPollSecretRejected(t *testing.T) { - s := newQRTestServer(t) + s, _ := newQRTestServer(t) start, code := qrStart(t, s, "Laptop") - _ = qrApprove(t, s, start.RequestID, code, "once", "approver-3") - - if w := qrStatus(s, start.RequestID, "not-the-secret"); w.Code != http.StatusForbidden { - t.Errorf("wrong poll secret: got %d, want 403", w.Code) - } - // The real secret still works afterwards (the bad attempt didn't consume it). - if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" { - t.Errorf("real secret after a bad attempt: got %q, want approved", got.Status) + _ = qrApprove(t, s, start.RequestID, code, "guest", "once", "approver-1") + w := qrStatus(s, start.RequestID, "not-the-secret") + if w.Code != http.StatusForbidden { + t.Fatalf("wrong poll secret: got %d, want 403", w.Code) } } -// Denial propagates to the polling device. func TestQRFlow_Deny(t *testing.T) { - s := newQRTestServer(t) + s, _ := newQRTestServer(t) start, code := qrStart(t, s, "Laptop") body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q}`, start.RequestID, code) r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/deny", strings.NewReader(body)) - r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "approver-4", SessionScope: "full"})) + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "approver-1", Scope: "full"})) w := httptest.NewRecorder() s.handleQRDeny(w, r) if w.Code != http.StatusNoContent { t.Fatalf("deny: got %d, want 204", w.Code) } if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "denied" { - t.Errorf("status after deny: got %q, want denied", got.Status) + t.Errorf("after deny: got %q, want denied", got.Status) } } -// With step-up enabled, approving without a fresh re-auth is refused (the gate -// rejects an empty step-up proof before any IdP call). AUTH.md §6. -func TestQRFlow_StepUpRequiredRejectsWithoutReauth(t *testing.T) { - s := newQRTestServer(t) - s.cfg.StepUpEnabled = true - start, code := qrStart(t, s, "Laptop") - if c := qrApprove(t, s, start.RequestID, code, "once", "approver-su"); c != http.StatusForbidden { - t.Errorf("approve without step-up proof: got %d, want 403", c) - } -} - -// A pending request long-polls then reports pending for the client to re-poll. func TestQRFlow_PendingLongPoll(t *testing.T) { - saved := qrStatusPollWindow + old := qrStatusPollWindow qrStatusPollWindow = 50 * time.Millisecond - defer func() { qrStatusPollWindow = saved }() + defer func() { qrStatusPollWindow = old }() - s := newQRTestServer(t) + s, _ := newQRTestServer(t) start, _ := qrStart(t, s, "Laptop") - w := qrStatus(s, start.RequestID, start.PollSecret) - if got := decodeStatus(t, w); got.Status != "pending" { - t.Errorf("pending long-poll: got %q, want pending", got.Status) + got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)) + if got.Status != "pending" { + t.Errorf("unapproved long-poll: got %q, want pending", got.Status) } } -// noopAuthStore satisfies jwtauth.Store for middleware wiring in tests (no device -// upsert happens without an X-Device-Name header). -type noopAuthStore struct{} - -func (noopAuthStore) UpsertDevice(context.Context, db.UpsertDeviceParams) error { return nil } -func (noopAuthStore) GetShortcutToken(context.Context, string) (db.ShortcutToken, error) { - return db.ShortcutToken{}, fmt.Errorf("none") -} -func (noopAuthStore) TouchShortcutTokenUsed(context.Context, db.TouchShortcutTokenUsedParams) error { - return nil -} -func (noopAuthStore) GetWebSession(_ context.Context, id string) (db.WebSession, error) { - return db.WebSession{ID: id}, nil // session always live in these tests -} - -// End-to-end through the REAL chain (auth.Middleware → requireFullSession): a -// guest session token is rejected 403 on an account-management route, a full one -// passes. This is the backend enforcement behind AUTH.md §3.2 (guest can't approve -// devices / remove devices / mint tokens). +// requireFullSession lets a full session through and blocks a restricted guest. func TestRequireFullSession_GuestBlockedFullPasses(t *testing.T) { - secret := "test-session-secret-at-least-32-bytes-ok" - srv := &Server{ - cfg: &config.Config{SessionTokenTTLSeconds: 900}, - sessionTokenKey: jwtauth.DeriveSessionTokenKey(secret), - } - guest, _, err := srv.mintSessionToken("u", "sid", "guest") - if err != nil { - t.Fatalf("mint guest: %v", err) - } - full, _, err := srv.mintSessionToken("u", "sid", "full") - if err != nil { - t.Fatalf("mint full: %v", err) - } - - a := jwtauth.New(&config.Config{AuthMode: "prod", SessionSecret: secret}, noopAuthStore{}) - handler := a.Middleware(requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - }))) - probe := func(tok string) int { - r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", nil) - r.Header.Set("Authorization", "Bearer "+tok) + })) + check := func(scope string) int { + r := httptest.NewRequest(http.MethodGet, "/x", nil) + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: scope})) w := httptest.NewRecorder() handler.ServeHTTP(w, r) return w.Code } - if c := probe(guest); c != http.StatusForbidden { - t.Errorf("guest token on requireFullSession route: got %d, want 403", c) + if c := check("app:cdrop:guest"); c != http.StatusForbidden { + t.Errorf("guest on full-only route: got %d, want 403", c) } - if c := probe(full); c != http.StatusOK { - t.Errorf("full token on requireFullSession route: got %d, want 200", c) + if c := check("app:cdrop:full"); c != http.StatusOK { + t.Errorf("full on full-only route: got %d, want 200", c) } } -// Revoking a session is sensitive: with step-up enabled it is refused (403 -// step_up_required) until the caller's session has recently stepped up, then it -// proceeds (here to 400 missing-id, i.e. past the gate). Step-up state is keyed on -// the cookie session, so it is per-device (AUTH.md §1/§6). -func TestSessionRevoke_RequiresStepUp(t *testing.T) { - s := newQRTestServer(t) - s.cfg.StepUpEnabled = true - s.cfg.StepUpMaxAgeSeconds = 300 - - raw, id, err := newSessionToken() - if err != nil { - t.Fatalf("token: %v", err) - } - now := time.Now().Unix() - if err := s.queries.CreateSelfSession(context.Background(), db.CreateSelfSessionParams{ - ID: id, UserID: "u", DeviceName: "", UserAgent: "", Kind: "oidc", Scope: "full", - GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600, - }); err != nil { - t.Fatalf("create session: %v", err) +// Deleting a device revokes its broker session (scoped by X-Broker-App) and drops the +// local row. +func TestDeleteDevice_RevokesBrokerSession(t *testing.T) { + s, st := newQRTestServer(t) + start, code := qrStart(t, s, "Old Phone") + _ = qrApprove(t, s, start.RequestID, code, "full", "persist", "owner") + got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)) + if got.DeviceID == "" { + t.Fatal("no device_id from collect") } - revoke := func() int { - r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/x", nil) - r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: raw}) - r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), - &jwtauth.Claims{UserID: "u", SessionScope: "full"})) - w := httptest.NewRecorder() - s.handleSessionRevoke(w, r) - return w.Code - } - - // Not stepped up → blocked before any deletion. - if c := revoke(); c != http.StatusForbidden { - t.Fatalf("revoke without step-up: got %d, want 403", c) - } - // Record step-up on this (cookie) session → gate now passes (400 = missing id, - // reached past the step-up check). - if err := s.queries.SetSessionSteppedUp(context.Background(), db.SetSessionSteppedUpParams{ - SteppedUpAt: now, ID: id, - }); err != nil { - t.Fatalf("set stepped up: %v", err) - } - if c := revoke(); c == http.StatusForbidden { - t.Errorf("revoke after step-up still 403; gate did not honour stepped_up_at") - } -} - -// selfTokenScope verifies a self-signed session token under the test's session -// key (proving the signature) and returns its scope claim. -func selfTokenScope(t *testing.T, token string) string { - t.Helper() - parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) - if err != nil { - t.Fatalf("parse token: %v", err) - } - var std jwt.Claims - custom := map[string]any{} - if err := parsed.Claims(jwtauth.DeriveSessionTokenKey(qrTestSecret), &std, &custom); err != nil { - t.Fatalf("verify token signature: %v", err) - } - if typ, _ := custom["typ"].(string); typ != "session" { - t.Fatalf("token typ: got %q, want session", typ) - } - scope, _ := custom["scope"].(string) - return scope -} - -// revokeByID drives handleSessionRevoke with the chi {id} param and full claims, -// step-up off (the gate is exercised separately). -func revokeByID(s *Server, id, userID string) *httptest.ResponseRecorder { - r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/"+id, nil) + r := httptest.NewRequest(http.MethodDelete, "/api/devices/"+got.DeviceID, nil) + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "owner", Scope: "full"})) rctx := chi.NewRouteContext() - rctx.URLParams.Add("id", id) - ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx) - ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: userID, SessionScope: "full"}) + rctx.URLParams.Add("device_id", got.DeviceID) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() - s.handleSessionRevoke(w, r.WithContext(ctx)) - return w -} - -// Revoking a web session removes its device registration too, so a revoked device -// disappears from the device list at once (the user can no longer remove devices -// by hand). AUTH.md §4. -func TestSessionRevoke_DeletesDevice(t *testing.T) { - s := newQRTestServer(t) // step-up off by default - ctx := context.Background() - now := time.Now().Unix() - - _, id, err := newSessionToken() - if err != nil { - t.Fatalf("token: %v", err) - } - if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{ - ID: id, UserID: "u", DeviceName: "Laptop", UserAgent: "", Kind: "oidc", Scope: "full", - GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600, - }); err != nil { - t.Fatalf("create session: %v", err) - } - if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{ - UserID: "u", Name: "Laptop", Type: "browser", LastSeen: now, - }); err != nil { - t.Fatalf("upsert device: %v", err) + s.handleDeleteDevice(w, r) + if w.Code != http.StatusNoContent { + t.Fatalf("delete device: got %d, want 204 (%s)", w.Code, w.Body.String()) } - if w := revokeByID(s, id, "u"); w.Code != http.StatusNoContent { - t.Fatalf("revoke: got %d %s, want 204", w.Code, w.Body.String()) + if !st.revoked["sid-1"] { + t.Error("broker session sid-1 was not revoked") } - if _, err := s.queries.GetWebSession(ctx, id); err == nil { - t.Errorf("session still present after revoke") + if st.lastRevokeApp != "cdrop" { + t.Errorf("revoke X-Broker-App: got %q, want cdrop", st.lastRevokeApp) } - devs, _ := s.queries.ListDevicesByUser(ctx, "u") - for _, d := range devs { - if d.Name == "Laptop" { - t.Errorf("device 'Laptop' not deleted on session revoke") - } + if _, err := s.queries.GetDevice(context.Background(), got.DeviceID); err == nil { + t.Error("device row should be gone after delete") } } -// The session list unifies web_sessions with native client devices (desktop / iOS) -// that have no web_session, while never double-listing a device that already has a -// session and excluding shortcut-token devices. AUTH.md §4. -func TestSessionsList_IncludesNativeDevices(t *testing.T) { - s := newQRTestServer(t) - ctx := context.Background() - now := time.Now().Unix() - - _, id, err := newSessionToken() - if err != nil { - t.Fatalf("token: %v", err) - } - if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{ - ID: id, UserID: "u", DeviceName: "Web", UserAgent: "", Kind: "oidc", Scope: "full", - GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600, - }); err != nil { - t.Fatalf("create session: %v", err) - } - for _, d := range []struct{ name, typ string }{ - {"Web", "browser"}, // has a web_session — must not be double-listed - {"Desk", "macos"}, // native, no session — must appear as native - {"iShortcut", "shortcut"}, // scoped token — excluded from session list - } { - if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{ - UserID: "u", Name: d.name, Type: d.typ, LastSeen: now, - }); err != nil { - t.Fatalf("upsert device %s: %v", d.name, err) - } - } +// The session list surfaces the user's devices with their tier as scope. +func TestSessionsList_ShowsDevices(t *testing.T) { + s, _ := newQRTestServer(t) + start, code := qrStart(t, s, "Tablet") + _ = qrApprove(t, s, start.RequestID, code, "guest", "once", "owner") + got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)) r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil) - r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), - &jwtauth.Claims{UserID: "u", SessionScope: "full"})) + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "owner", Scope: "app:cdrop:guest", DeviceID: got.DeviceID})) w := httptest.NewRecorder() s.handleSessionsList(w, r) if w.Code != http.StatusOK { - t.Fatalf("sessions list: got %d %s", w.Code, w.Body.String()) + t.Fatalf("sessions list: %d %s", w.Code, w.Body.String()) } var resp struct { Sessions []sessionView `json:"sessions"` @@ -441,96 +381,132 @@ func TestSessionsList_IncludesNativeDevices(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } - var web, webNativeDup, desk, shortcut bool - for _, v := range resp.Sessions { - switch { - case v.DeviceName == "Web" && !v.Native && v.Kind == "oidc": - web = true - case v.DeviceName == "Web" && v.Native: - webNativeDup = true - case v.DeviceName == "Desk" && v.Native && v.Kind == "macos": - desk = true - case v.DeviceName == "iShortcut": - shortcut = true - } + if len(resp.Sessions) != 1 { + t.Fatalf("session count: got %d, want 1", len(resp.Sessions)) } - if !web { - t.Errorf("web session for 'Web' missing") - } - if webNativeDup { - t.Errorf("'Web' double-listed as a native device") - } - if !desk { - t.Errorf("native device 'Desk' missing from session list") - } - if shortcut { - t.Errorf("shortcut device leaked into session list") + sv := resp.Sessions[0] + if sv.DeviceID != got.DeviceID || sv.Scope != "guest" || !sv.Current { + t.Errorf("session view wrong: %+v", sv) } } -// The sweeper drops browser devices whose web_session is gone (orphans) while -// keeping browser devices that still have a live session and all native devices. -func TestDeleteOrphanBrowserDevices(t *testing.T) { - s := newQRTestServer(t) - ctx := context.Background() - now := time.Now().Unix() - - // Browser "Kept" has a live session; browser "Orphan" has none; native "Desk" - // (macos) legitimately has no session and must survive. - _, id, err := newSessionToken() - if err != nil { - t.Fatalf("token: %v", err) +// deviceSession drives the 代铸 endpoint with the given identity and returns the response. +func deviceSession(t *testing.T, s *Server, userID, scope, deviceID, name, dtype, origin string) deviceSessionResp { + t.Helper() + body := fmt.Sprintf(`{"device_id":%q,"device_name":%q,"device_type":%q}`, deviceID, name, dtype) + r := httptest.NewRequest(http.MethodPost, "/api/auth/device-session", strings.NewReader(body)) + if origin != "" { + r.Header.Set("Origin", origin) } - if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{ - ID: id, UserID: "u", DeviceName: "Kept", Kind: "oidc", Scope: "full", - CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600, - }); err != nil { - t.Fatalf("create session: %v", err) - } - for _, d := range []struct{ name, typ string }{ - {"Kept", "browser"}, {"Orphan", "browser"}, {"Desk", "macos"}, - } { - if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{ - UserID: "u", Name: d.name, Type: d.typ, LastSeen: now, - }); err != nil { - t.Fatalf("upsert %s: %v", d.name, err) - } - } - - if _, err := s.queries.DeleteOrphanBrowserDevices(ctx, now); err != nil { - t.Fatalf("sweep: %v", err) - } - devs, _ := s.queries.ListDevicesByUser(ctx, "u") - got := map[string]bool{} - for _, d := range devs { - got[d.Name] = true - } - if !got["Kept"] { - t.Errorf("browser 'Kept' with a live session was wrongly swept") - } - if got["Orphan"] { - t.Errorf("orphan browser 'Orphan' was not swept") - } - if !got["Desk"] { - t.Errorf("native 'Desk' was wrongly swept (it keeps no web_session)") - } -} - -// Removing a native device (the session list's logout path for desktop / iOS) is -// sensitive and requires a recent step-up, mirroring web-session revocation. -func TestDeleteDevice_RequiresStepUp(t *testing.T) { - s := newQRTestServer(t) - s.cfg.StepUpEnabled = true - s.cfg.StepUpMaxAgeSeconds = 300 - - r := httptest.NewRequest(http.MethodDelete, "/api/devices/Desk", nil) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("name", "Desk") - ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx) - ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: "u", SessionScope: "full"}) + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: userID, Name: "Commilitia", Avatar: "https://example.net/a.png", Scope: scope})) w := httptest.NewRecorder() - s.handleDeleteDevice(w, r.WithContext(ctx)) - if w.Code != http.StatusForbidden { - t.Fatalf("delete device without step-up: got %d, want 403", w.Code) + s.handleDeviceSession(w, r) + if w.Code != http.StatusOK { + t.Fatalf("device-session: %d %s", w.Code, w.Body.String()) + } + var resp deviceSessionResp + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode device-session: %v", err) + } + return resp +} + +// listSessions calls the session-management list for a caller riding device deviceID. +func listSessions(t *testing.T, s *Server, userID, scope, deviceID string) []sessionView { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil) + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: userID, Scope: scope, DeviceID: deviceID})) + w := httptest.NewRecorder() + s.handleSessionsList(w, r) + if w.Code != http.StatusOK { + t.Fatalf("sessions list: %d %s", w.Code, w.Body.String()) + } + var resp struct { + Sessions []sessionView `json:"sessions"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode list: %v", err) + } + return resp.Sessions +} + +// 代铸 turns an edge-verified login into a managed device session that joins the unified +// list as the current device, with its type overlaid and the verified display name returned. +func TestDeviceSession_MintsManagedDevice(t *testing.T) { + s, st := newQRTestServer(t) + resp := deviceSession(t, s, "owner", "full", "dev_browser01", "Laptop", "browser", "") + if resp.AccessToken == "" || resp.RefreshToken == "" { + t.Fatal("device-session returned no tokens") + } + if resp.DeviceID != "dev_browser01" { + t.Errorf("device_id: got %q", resp.DeviceID) + } + if resp.Name != "Commilitia" { + t.Errorf("name: got %q, want the verified X-Auth-Name not the subject UUID", resp.Name) + } + if resp.Avatar != "https://example.net/a.png" { + t.Errorf("avatar: got %q, want the verified X-Auth-Avatar", resp.Avatar) + } + if st.lastMint["tier"] != "full" || st.lastMint["meta"] != "dev_browser01" || st.lastMint["label"] != "Laptop" { + t.Errorf("mint params: %+v", st.lastMint) + } + list := listSessions(t, s, "owner", "app:cdrop:full", "dev_browser01") + if len(list) != 1 || list[0].DeviceID != "dev_browser01" || list[0].Kind != "browser" || !list[0].Current { + t.Errorf("unified list wrong: %+v", list) + } +} + +// Re-login with the same device_id rotates the one session (R2), not piling up duplicates, +// and adopts the latest label — the fix for the "duplicate phantom devices" regression. +func TestDeviceSession_Idempotent(t *testing.T) { + s, _ := newQRTestServer(t) + _ = deviceSession(t, s, "owner", "full", "dev_same01", "Laptop", "browser", "") + _ = deviceSession(t, s, "owner", "full", "dev_same01", "Laptop Renamed", "browser", "") + list := listSessions(t, s, "owner", "app:cdrop:full", "dev_same01") + if len(list) != 1 { + t.Fatalf("idempotent re-mint: got %d sessions, want 1", len(list)) + } + if list[0].DeviceName != "Laptop Renamed" { + t.Errorf("rotation should adopt the latest label: got %q", list[0].DeviceName) + } +} + +// A meta-less machine session (a desktop device-authorize bootstrap) must not surface as a +// phantom device — the unified list filters it out, leaving only真正的托管设备. +func TestSessionsList_FiltersMetalessBootstrap(t *testing.T) { + s, _ := newQRTestServer(t) + if _, err := s.broker.MintSession(context.Background(), brokerclient.MintParams{ + UserID: "owner", Tier: "full", Label: "bootstrap", + }); err != nil { + t.Fatalf("bootstrap mint: %v", err) + } + _ = deviceSession(t, s, "owner", "full", "dev_real01", "Laptop", "browser", "") + list := listSessions(t, s, "owner", "app:cdrop:full", "dev_real01") + if len(list) != 1 || list[0].DeviceID != "dev_real01" { + t.Fatalf("metaless bootstrap not filtered: %+v", list) + } +} + +// A cookie-authenticated mint must carry a same-origin Origin; a cross-site forgery (which +// would reintroduce phantom devices) is rejected. +func TestDeviceSession_RejectsCrossOrigin(t *testing.T) { + s, _ := newQRTestServer(t) + body := `{"device_id":"dev_x01","device_name":"X","device_type":"browser"}` + r := httptest.NewRequest(http.MethodPost, "/api/auth/device-session", strings.NewReader(body)) + r.Header.Set("Origin", "https://evil.example.net") + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "owner", Scope: "full"})) + w := httptest.NewRecorder() + s.handleDeviceSession(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-origin device-session: got %d, want 403", w.Code) + } +} + +// A restricted guest minting its device session stays guest — no escalation to full. +func TestDeviceSession_GuestTierNotEscalated(t *testing.T) { + s, st := newQRTestServer(t) + _ = deviceSession(t, s, "owner", "app:cdrop:guest", "dev_guest01", "Borrowed", "browser", "") + if st.lastMint["tier"] != "guest" { + t.Errorf("guest caller minted tier %v, want guest", st.lastMint["tier"]) } } diff --git a/internal/httpapi/refresh.go b/internal/httpapi/refresh.go new file mode 100644 index 0000000..db96142 --- /dev/null +++ b/internal/httpapi/refresh.go @@ -0,0 +1,61 @@ +package httpapi + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "time" +) + +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"` +} + +// handleAuthRefresh proxies a token refresh to the Auth Broker. The SPA holds a +// broker-minted access + refresh; when the access expires it POSTs the refresh token +// here and cdrop relays it to the broker's /refresh, returning the rotated pair. The +// proxy keeps the browser same-origin (no broker CORS) and means cdrop stores no +// credential — it only forwards. Public (the access token is expired, which is the +// point); Origin-checked for CSRF. +func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { + if !s.sameOrigin(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) + return + } + var req refreshReq + if err := json.NewDecoder(io.LimitReader(r.Body, 8192)).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 + } + + res, err := s.broker.RefreshSession(r.Context(), req.RefreshToken) + if err != nil { + // A rejected refresh (expired / rotated / revoked) is the client's cue to + // re-authenticate; relay it as 401. (A broker outage also lands here — rare, + // and re-login is the safe fallback.) + slog.Warn("broker refresh failed", "err", err) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "refresh rejected"}) + return + } + + expiresIn := int(res.AccessExpires - time.Now().Unix()) + if expiresIn < 0 { + expiresIn = 0 + } + writeJSON(w, http.StatusOK, refreshResp{ + AccessToken: res.Access, + RefreshToken: res.Refresh, + ExpiresIn: expiresIn, + }) +} diff --git a/internal/httpapi/selftoken.go b/internal/httpapi/selftoken.go deleted file mode 100644 index fd1b643..0000000 --- a/internal/httpapi/selftoken.go +++ /dev/null @@ -1,39 +0,0 @@ -package httpapi - -import ( - "time" - - "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" -) - -// mintSessionToken signs a short-lived cdrop self-signed session access token -// (AUTH.md §3.1): HS256 over the SessionSecret-derived key, sub=userID, sid=the -// web_sessions row id, typ=session, scope=full|guest. The matching verifier is -// jwtauth.verifySelfToken. Returns the token and its lifetime in seconds (for the -// client's expires_in). Used by scan-login and self/guest session refresh — never -// touches the IdP. -func (s *Server) mintSessionToken(userID, sid, scope string) (string, int, error) { - ttl := time.Duration(s.cfg.SessionTokenTTLSeconds) * time.Second - now := time.Now() - sig, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.HS256, Key: s.sessionTokenKey}, - (&jose.SignerOptions{}).WithType("JWT"), - ) - if err != nil { - return "", 0, err - } - std := jwt.Claims{ - Subject: userID, - IssuedAt: jwt.NewNumericDate(now), - Expiry: jwt.NewNumericDate(now.Add(ttl)), - } - tok, err := jwt.Signed(sig). - Claims(std). - Claims(map[string]any{"typ": "session", "scope": scope, "sid": sid}). - Serialize() - if err != nil { - return "", 0, err - } - return tok, int(ttl / time.Second), nil -} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 9a9abd5..deac452 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -6,13 +6,13 @@ import ( "encoding/json" "log/slog" "net/http" - "sync" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httprate" + "commilitia.net/cdrop/internal/brokerclient" "commilitia.net/cdrop/internal/calls" "commilitia.net/cdrop/internal/clipboard" "commilitia.net/cdrop/internal/config" @@ -38,23 +38,11 @@ type Server struct { push *push.Sender // inert when VAPID keys unset → push endpoints 503 mux *chi.Mux - // sessionKey encrypts browser refresh_tokens at rest (web_sessions); nil when - // CDROP_SESSION_SECRET is unset (dev), which disables passwordless re-login. - // siteOrigin is the deployment's scheme://host, used for the CSRF Origin check. - sessionKey []byte + // broker delegates session lifecycle to the Auth Broker (mint / revoke / refresh). + broker *brokerclient.Client + // siteOrigin is the deployment's scheme://host, used for the CSRF Origin check + // and the scan-login QR link. siteOrigin string - - // sessionTokenKey signs cdrop's self-signed session access tokens (scan-login; - // AUTH.md §3.1), derived from SessionSecret in a separate domain from sessionKey. - // nil when SessionSecret is unset → minting is unavailable (QR stays off). - sessionTokenKey []byte - - // refreshLocks serialise concurrent refreshes of the same web session (all of - // a user's browser tabs share one cookie → one refresh_token). Striped so the - // lock set stays bounded; collisions just serialise unrelated sessions, which - // is harmless. Prevents a multi-tab race from spending a one-time-use - // refresh_token twice and logging the user out everywhere. - refreshLocks [refreshLockStripes]sync.Mutex } func New( @@ -82,9 +70,8 @@ func New( push: pushSender, mux: chi.NewRouter(), - sessionKey: deriveSessionKey(cfg.SessionSecret), - siteOrigin: deriveSiteOrigin(cfg.OIDCRedirectURI), - sessionTokenKey: jwtauth.DeriveSessionTokenKey(cfg.SessionSecret), + broker: brokerclient.New(cfg.BrokerBaseURL, cfg.BrokerInternalKey, cfg.BrokerAppOrDefault()), + siteOrigin: deriveSiteOrigin(cfg.PublicURL), } s.routes() return s @@ -105,99 +92,94 @@ func (s *Server) routes() { s.mux.NotFound(webui.Handler().ServeHTTP) s.mux.Route("/api", func(r chi.Router) { - // Public OIDC PKCE plumbing — no auth required (it bootstraps it). + // Public navigation: 302 to the broker's global-SSO login (bootstraps login). + r.Get("/auth/login", s.handleAuthLogin) + // Public: broker coordinates for native clients' device-authorization flow. r.Get("/auth/config", s.handleAuthConfig) - // Rate-limit the credential-bearing OAuth endpoints (G4): they proxy to - // the IdP, so without a cap cdrop is a brute-force / token-pivot relay. - // Per-IP (RealIP is mounted above); 60/min is ample for real logins and - // hourly refresh even behind a shared NAT, while choking a scripted flood. + + // Public, rate-limited (per-IP; RealIP is mounted above). No bearer: the new + // device isn't logged in yet (its poll_secret is the only credential), and + // refresh runs precisely when the access token is expired. 60/min is ample + // for real use behind a shared NAT while choking a scripted flood. r.Group(func(r chi.Router) { r.Use(httprate.LimitByIP(60, time.Minute)) - r.Post("/auth/exchange", s.handleAuthExchange) + // Token refresh — proxied to the Auth Broker, so the SPA stays same-origin + // and cdrop stores no credential (it just relays the rotated pair). r.Post("/auth/refresh", s.handleAuthRefresh) - // Cookie-authenticated (no bearer): the HttpOnly session cookie is the - // only credential. logout drops the server session; device persists - // this browser's name into the session for PWA-eviction recovery. - r.Post("/auth/logout", s.handleAuthLogout) - r.Post("/auth/device", s.handleAuthDevice) - // Scan-login: the new device opens a request and long-polls status. - // Public (no bearer — the device isn't logged in yet); the private - // poll_secret in the X-Poll-Secret header is the only credential. + // Scan-login: the new device opens a request and long-polls status. The + // private poll_secret in the X-Poll-Secret header is the only credential. r.Post("/auth/qr/start", s.handleQRStart) r.Get("/auth/qr/status", s.handleQRStatus) }) - // Protected routes. gzip / compress is intentionally NOT mounted — + // Authenticated routes. Every request past here carries the broker's + // edge-injected X-Auth-* identity (prod) or a dev token. Guests and full + // sessions both reach this tier. gzip / compress is intentionally NOT mounted — // it would buffer the SSE stream. r.Group(func(r chi.Router) { r.Use(s.auth.Middleware) - // Clipboard is the one capability a scoped shortcut token may reach - // (iOS Shortcut sync) — it must carry the "clipboard" scope. Full - // login sessions pass requireScope unconditionally. + // Clipboard, transfer, messaging, presence, push — guests included. + r.Get("/clipboard", s.handleClipboardGet) + r.Put("/clipboard", s.handleClipboardPut) + // Lightweight version probe — no content; lets pollers detect change + // cheaply before fetching the full body. + r.Get("/clipboard/version", s.handleClipboardVersion) + + r.Get("/me", s.handleMe) + r.Post("/me/disconnect", s.handleDisconnect) + // 代铸: turn an edge-verified login (SSO cookie or device-authorize bootstrap) + // into a cdrop-managed device session bound to a stable device_id, so browser + // and native clients share one device list. Authenticated but not full-only — + // it mints at the caller's own tier. Rate-limited per IP: with R2 idempotency a + // normal client mints about once per login, so a generous cap simply bounds an + // authenticated identity that loops fresh device_ids to mint unbounded sessions. r.Group(func(r chi.Router) { - r.Use(requireScope(shortcutScope)) - r.Get("/clipboard", s.handleClipboardGet) - r.Put("/clipboard", s.handleClipboardPut) - // Lightweight version probe — no content; lets pollers (iOS - // Shortcut) detect change cheaply before fetching the full body. - r.Get("/clipboard/version", s.handleClipboardVersion) + r.Use(httprate.LimitByIP(20, time.Minute)) + r.Post("/auth/device-session", s.handleDeviceSession) + }) + // Logout revokes the calling device's own broker session (self-service). + r.Post("/auth/logout", s.handleLogout) + r.Get("/hub/events", s.handleEvents) + r.Post("/hub/signal", s.handleSignal) + r.Post("/message", s.handleMessage) + r.Get("/devices", s.handleDevices) + r.Get("/push/vapid-key", s.handlePushVAPIDKey) + r.Post("/push/subscribe", s.handlePushSubscribe) + r.Delete("/push/subscribe", s.handlePushUnsubscribe) + r.Get("/calls/credentials", s.handleCallsCredentials) + + // Full sessions only — restricted guests (scan-login borrows) are + // rejected: they transfer files but can't manage devices, approve other + // devices, or revoke sessions. + r.Group(func(r chi.Router) { + r.Use(requireFullSession) + r.Delete("/devices/{device_id}", s.handleDeleteDevice) + r.Patch("/devices/{device_id}", s.handleRenameDevice) + // Scan-login approval side: the logged-in approver views and authorises + // the new device. Guests can't reach here, so a borrowed device can't + // approve further devices. + r.Get("/auth/qr/request", s.handleQRRequest) + r.Post("/auth/qr/approve", s.handleQRApprove) + r.Post("/auth/qr/deny", s.handleQRDeny) + // Session management: list logged-in devices with their permission + // level and revoke one (logout that device). + r.Get("/auth/sessions", s.handleSessionsList) + r.Delete("/auth/sessions/{id}", s.handleSessionRevoke) }) - // Everything else requires a full login session; scoped shortcut - // tokens are rejected, so a leaked token's blast radius stays the - // clipboard and nothing more (including token self-management). - r.Group(func(r chi.Router) { - r.Use(rejectScoped) - r.Get("/me", s.handleMe) - r.Post("/me/disconnect", s.handleDisconnect) - r.Get("/hub/events", s.handleEvents) - r.Post("/hub/signal", s.handleSignal) - r.Post("/message", s.handleMessage) - r.Get("/devices", s.handleDevices) - r.Get("/push/vapid-key", s.handlePushVAPIDKey) - r.Post("/push/subscribe", s.handlePushSubscribe) - r.Delete("/push/subscribe", s.handlePushUnsubscribe) - r.Get("/calls/credentials", s.handleCallsCredentials) - - // Account-management surface: restricted guest (scan-login borrow) - // sessions are rejected here too — they can transfer files but not - // remove devices, nor mint / list / revoke long-lived shortcut - // tokens. Full / OIDC / dev sessions pass (AUTH.md §3.2). - r.Group(func(r chi.Router) { - r.Use(requireFullSession) - r.Delete("/devices/{name}", s.handleDeleteDevice) - r.Post("/shortcut/issue", s.handleShortcutIssue) - r.Get("/shortcut", s.handleShortcutList) - r.Delete("/shortcut/{jti}", s.handleShortcutRevoke) - // Scan-login approval side: the logged-in approver views and - // authorises the new device. Guest sessions can't reach here, so a - // borrowed device can't approve further devices. - r.Get("/auth/qr/request", s.handleQRRequest) - r.Post("/auth/qr/approve", s.handleQRApprove) - r.Post("/auth/qr/deny", s.handleQRDeny) - // Session management: list logins with their permission level, - // really revoke one (logout), and the standalone step-up the - // revoke flow re-uses. All under /api/auth so the session cookie - // (Path=/api/auth) is available for step-up state. - r.Post("/auth/stepup", s.handleStepUp) - r.Get("/auth/sessions", s.handleSessionsList) - r.Delete("/auth/sessions/{id}", s.handleSessionRevoke) - }) - - r.Route("/transfer", func(r chi.Router) { - r.Post("/initiate", s.handleTransferInit) - r.Post("/{id}/accept", s.transitionHandler(transfer.StateAccepted, "")) - r.Post("/{id}/cancel", s.transitionHandler(transfer.StateCancelled, "")) - r.Post("/{id}/p2p", s.transitionHandler(transfer.StateP2PActive, transfer.ModeP2P)) - r.Post("/{id}/fallback", s.transitionHandler(transfer.StateRelayActive, transfer.ModeRelay)) - r.Post("/{id}/done", s.transitionHandler(transfer.StateDone, "")) - r.Post("/{id}/fail", s.transitionHandler(transfer.StateFailed, "")) - }) - r.Route("/relay/{id}", func(r chi.Router) { - r.Post("/chunk", s.handleRelayChunk) - r.Get("/stream", s.handleRelayStream) - }) + r.Route("/transfer", func(r chi.Router) { + r.Post("/initiate", s.handleTransferInit) + r.Post("/{id}/accept", s.transitionHandler(transfer.StateAccepted, "")) + r.Post("/{id}/cancel", s.transitionHandler(transfer.StateCancelled, "")) + r.Post("/{id}/p2p", s.transitionHandler(transfer.StateP2PActive, transfer.ModeP2P)) + r.Post("/{id}/fallback", s.transitionHandler(transfer.StateRelayActive, transfer.ModeRelay)) + r.Post("/{id}/done", s.transitionHandler(transfer.StateDone, "")) + r.Post("/{id}/fail", s.transitionHandler(transfer.StateFailed, "")) + }) + r.Route("/relay/{id}", func(r chi.Router) { + r.Post("/chunk", s.handleRelayChunk) + r.Get("/stream", s.handleRelayStream) }) }) }) @@ -223,12 +205,16 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { } type meResp struct { - UserID string `json:"user_id"` + UserID string `json:"user_id"` + // Name is the display name from the broker (X-Auth-Name); falls back to user_id. + Name string `json:"name"` + // Avatar is the broker account's picture URL (X-Auth-Avatar); empty when none. + Avatar string `json:"avatar,omitempty"` Groups []string `json:"groups"` DeviceName string `json:"device_name"` // Scope is the session's capability level: "guest" (scan-login borrow, // capability-limited) or "full" (normal login). The UI hides account-management - // affordances on guest sessions (AUTH.md §3.2). + // affordances on guest sessions. Scope string `json:"scope"` } @@ -243,8 +229,14 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { if claims.Guest() { scope = "guest" } + name := claims.Name + if name == "" { + name = claims.UserID + } writeJSON(w, http.StatusOK, meResp{ UserID: claims.UserID, + Name: name, + Avatar: claims.Avatar, Groups: claims.Groups, DeviceName: device, Scope: scope, diff --git a/internal/httpapi/session.go b/internal/httpapi/session.go index f85abec..cab5b30 100644 --- a/internal/httpapi/session.go +++ b/internal/httpapi/session.go @@ -2,183 +2,46 @@ package httpapi import ( "context" - "crypto/aes" - "crypto/cipher" - "crypto/rand" "crypto/sha256" - "encoding/base64" "encoding/hex" - "encoding/json" - "errors" - "io" "log/slog" "net/http" "net/url" "time" "commilitia.net/cdrop/internal/db" - "commilitia.net/cdrop/internal/jwtauth" ) -// Browser "passwordless re-login" (web only — desktop persists its own -// refresh_token in the OS keyring and never touches these endpoints). -// -// The durable credential (Casdoor's ~15 KB refresh_token) stays server-side in -// web_sessions, encrypted at rest. The browser only ever holds an opaque cookie -// token whose SHA-256 is the row's primary key — so a leaked DB yields neither a -// usable cookie nor a decryptable token. The cookie is HttpOnly (XSS can't read -// it), Secure (HTTPS only), SameSite=Lax + Origin-checked (CSRF), and Path-scoped -// to /api/auth so it rides only these four endpoints, not every API call. +// Shared HTTP helpers for the auth surface. After the Auth Broker migration (path A) +// cdrop no longer keeps server-side sessions, cookies, or refresh tokens — identity +// and session lifecycle live in the broker. What remains here is the CSRF origin +// check, the scan-login poll-secret hash, and the login-request reaper. -const ( - sessionCookieName = "cdrop_session" - sessionCookiePath = "/api/auth" - - // webSessionTTL is the sliding inactivity window: each refresh pushes - // expires_at this far forward. The effective cap is min(this, the IdP's own - // refresh_token validity) — once Casdoor retires the refresh_token, refresh - // 401s and the user re-logs in regardless. - webSessionTTL = 7 * 24 * time.Hour - - // refreshLockStripes bounds the per-session refresh lock set (see Server). - refreshLockStripes = 256 -) - -// lockRefresh serialises refreshes of one session id, returning the unlock fn. -// Callers must re-read the session row after acquiring it: a concurrent refresh -// may have already rotated the refresh_token. -func (s *Server) lockRefresh(id string) func() { - idx := stripeIndex(id) - s.refreshLocks[idx].Lock() - return func() { s.refreshLocks[idx].Unlock() } -} - -// stripeIndex folds a session id into a stripe with FNV-1a — bounded, no cleanup. -func stripeIndex(id string) int { - var h uint32 = 2166136261 - for i := 0; i < len(id); i++ { - h = (h ^ uint32(id[i])) * 16777619 - } - return int(h % refreshLockStripes) -} - -// deriveSessionKey turns a config secret of any length into a 32-byte AES key -// (mirrors jwtauth.DeriveHS256Key). Empty secret → nil (feature disabled). -func deriveSessionKey(secret string) []byte { - if secret == "" { - return nil - } - sum := sha256.Sum256([]byte(secret)) - return sum[:] -} - -// deriveSiteOrigin extracts scheme://host from the configured redirect_uri to -// give the CSRF Origin check a fixed expected value. Empty (dev) disables it. -func deriveSiteOrigin(redirectURI string) string { - if redirectURI == "" { +// deriveSiteOrigin extracts scheme://host from the configured public URL to give the +// CSRF Origin check a fixed expected value (and the scan-login QR its link origin). +// Empty / unparseable (dev) disables the origin check. +func deriveSiteOrigin(publicURL string) string { + if publicURL == "" { return "" } - u, err := url.Parse(redirectURI) + u, err := url.Parse(publicURL) if err != nil || u.Scheme == "" || u.Host == "" { return "" } return u.Scheme + "://" + u.Host } -// newSessionToken mints a fresh opaque cookie token plus its storage id. raw -// goes in Set-Cookie; id (hex SHA-256 of raw) is the DB key, so the stored row -// never contains a usable cookie value. -func newSessionToken() (raw, id string, err error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", "", err - } - raw = base64.RawURLEncoding.EncodeToString(b) - return raw, sessionID(raw), nil -} - +// sessionID hashes an opaque token to its storage form (hex SHA-256). The scan-login +// poll_secret is stored as this hash — the new device holds the plaintext, so a leaked +// DB never yields a usable secret. func sessionID(raw string) string { sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } -// encryptRefresh seals a refresh_token with AES-256-GCM. Output is -// base64(nonce || ciphertext+tag); the key is s.sessionKey (env-derived). -func (s *Server) encryptRefresh(plain string) (string, error) { - if len(s.sessionKey) == 0 { - return "", errors.New("session key not configured") - } - gcm, err := newGCM(s.sessionKey) - if err != nil { - return "", err - } - nonce := make([]byte, gcm.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return "", err - } - ct := gcm.Seal(nonce, nonce, []byte(plain), nil) - return base64.StdEncoding.EncodeToString(ct), nil -} - -func (s *Server) decryptRefresh(enc string) (string, error) { - if len(s.sessionKey) == 0 { - return "", errors.New("session key not configured") - } - raw, err := base64.StdEncoding.DecodeString(enc) - if err != nil { - return "", err - } - gcm, err := newGCM(s.sessionKey) - if err != nil { - return "", err - } - if len(raw) < gcm.NonceSize() { - return "", errors.New("ciphertext too short") - } - nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] - plain, err := gcm.Open(nil, nonce, ct, nil) - if err != nil { - return "", err - } - return string(plain), nil -} - -func newGCM(key []byte) (cipher.AEAD, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - return cipher.NewGCM(block) -} - -func setSessionCookie(w http.ResponseWriter, raw string) { - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: raw, - Path: sessionCookiePath, - MaxAge: int(webSessionTTL / time.Second), - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - }) -} - -func clearSessionCookie(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: "", - Path: sessionCookiePath, - MaxAge: -1, - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - }) -} - -// sameOrigin is belt-and-suspenders CSRF defence atop SameSite=Lax: when the -// browser sends an Origin header (always, on fetch POST) it must match the -// deployment's own origin. Absent Origin (non-browser clients) is allowed, as is -// an unconfigured site origin (dev). +// sameOrigin is belt-and-suspenders CSRF defence: when the browser sends an Origin +// header (always, on fetch POST) it must match the deployment's own origin. Absent +// Origin (non-browser clients) is allowed, as is an unconfigured site origin (dev). func (s *Server) sameOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" || s.siteOrigin == "" { @@ -187,61 +50,17 @@ func (s *Server) sameOrigin(r *http.Request) bool { return origin == s.siteOrigin } -// handleAuthLogout destroys the server-side session and clears the cookie. -// Cookie-authenticated (no bearer): the cookie is the only thing that proves -// which session to drop. -func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { - if err := s.queries.DeleteWebSession(r.Context(), sessionID(c.Value)); err != nil { - slog.Warn("web session delete failed", "err", err) - } +func truncate(s string, n int) string { + if len(s) <= n { + return s } - clearSessionCookie(w) - w.WriteHeader(http.StatusNoContent) + return s[:n] + "…" } -type sessionDeviceReq struct { - DeviceName string `json:"device_name"` -} - -// handleAuthDevice persists this browser's device name into its session row so -// it survives PWA storage eviction: on the next boot, /auth/refresh hands the -// name back and the client re-hydrates selfDeviceName without a trip to /setup. -func (s *Server) handleAuthDevice(w http.ResponseWriter, r *http.Request) { - if !s.sameOrigin(r) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) - return - } - c, err := r.Cookie(sessionCookieName) - if err != nil || c.Value == "" { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"}) - return - } - var req sessionDeviceReq - if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - name := jwtauth.SanitizeDeviceName(req.DeviceName) - if name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty device name"}) - return - } - if err := s.queries.SetWebSessionDevice(r.Context(), db.SetWebSessionDeviceParams{ - DeviceName: name, - ID: sessionID(c.Value), - }); err != nil { - slog.Error("set web session device failed", "err", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "persist failed"}) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// RunWebSessionReaper periodically reclaims expired web_sessions rows. Lazy -// deletion on access covers the hot path; this sweeps sessions that simply go -// idle and are never touched again. -func RunWebSessionReaper(ctx context.Context, q *db.Queries) { +// RunLoginRequestReaper periodically reclaims expired scan-login request rows. They +// are short-lived (default 120s) and already rejected at use time; this sweeps the +// stragglers that are opened and never collected. +func RunLoginRequestReaper(ctx context.Context, q *db.Queries) { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { @@ -249,16 +68,7 @@ func RunWebSessionReaper(ctx context.Context, q *db.Queries) { case <-ctx.Done(): return case <-ticker.C: - now := time.Now().Unix() - n, err := q.DeleteExpiredWebSessions(ctx, now) - if err != nil { - slog.Warn("web session reaper failed", "err", err) - } else if n > 0 { - slog.Info("web sessions reaped", "count", n) - } - // Scan-login requests are short-lived (default 120s); sweep the - // stragglers here too. Expired rows are already rejected at use time. - if n, err := q.DeleteExpiredLoginRequests(ctx, now); err != nil { + if n, err := q.DeleteExpiredLoginRequests(ctx, time.Now().Unix()); err != nil { slog.Warn("login request reaper failed", "err", err) } else if n > 0 { slog.Info("login requests reaped", "count", n) diff --git a/internal/httpapi/session_test.go b/internal/httpapi/session_test.go deleted file mode 100644 index c0d2c04..0000000 --- a/internal/httpapi/session_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package httpapi - -import ( - "encoding/base64" - "strings" - "testing" -) - -func TestEncryptRefreshRoundTrip(t *testing.T) { - s := &Server{sessionKey: deriveSessionKey("a-strong-session-secret")} - plain := strings.Repeat("refresh.token.", 2000) // ~28 KB, mimics Casdoor's large JWT - - enc, err := s.encryptRefresh(plain) - if err != nil { - t.Fatalf("encrypt: %v", err) - } - if strings.Contains(enc, plain) { - t.Fatal("ciphertext leaks plaintext") - } - got, err := s.decryptRefresh(enc) - if err != nil { - t.Fatalf("decrypt: %v", err) - } - if got != plain { - t.Fatalf("roundtrip mismatch: len(got)=%d, len(want)=%d", len(got), len(plain)) - } -} - -func TestEncryptRefreshNonceIsRandom(t *testing.T) { - s := &Server{sessionKey: deriveSessionKey("secret")} - a, _ := s.encryptRefresh("same plaintext") - b, _ := s.encryptRefresh("same plaintext") - if a == b { - t.Fatal("two encryptions of the same plaintext are identical — nonce not random") - } -} - -func TestDecryptRefreshRejectsTamper(t *testing.T) { - s := &Server{sessionKey: deriveSessionKey("secret")} - enc, err := s.encryptRefresh("token") - if err != nil { - t.Fatalf("encrypt: %v", err) - } - raw, err := base64.StdEncoding.DecodeString(enc) - if err != nil { - t.Fatalf("decode: %v", err) - } - raw[len(raw)-1] ^= 0xFF // flip a bit in the GCM tag - if _, err := s.decryptRefresh(base64.StdEncoding.EncodeToString(raw)); err == nil { - t.Fatal("tampered ciphertext must fail authentication") - } -} - -func TestDecryptRefreshRejectsWrongKey(t *testing.T) { - enc, err := (&Server{sessionKey: deriveSessionKey("key-one")}).encryptRefresh("token") - if err != nil { - t.Fatalf("encrypt: %v", err) - } - if _, err := (&Server{sessionKey: deriveSessionKey("key-two")}).decryptRefresh(enc); err == nil { - t.Fatal("decryption under a different key must fail") - } -} - -func TestEncryptRefreshNoKey(t *testing.T) { - s := &Server{sessionKey: nil} - if _, err := s.encryptRefresh("token"); err == nil { - t.Fatal("encrypt without a key must error") - } -} - -func TestSessionIDDeterministicAndHashed(t *testing.T) { - raw, id, err := newSessionToken() - if err != nil { - t.Fatalf("newSessionToken: %v", err) - } - if id != sessionID(raw) { - t.Fatal("newSessionToken id must equal sessionID(raw)") - } - if id == raw || strings.Contains(id, raw) { - t.Fatal("stored id must be a hash of the cookie value, not the value itself") - } - // SHA-256 hex is always 64 chars. - if len(id) != 64 { - t.Fatalf("session id length: got %d, want 64", len(id)) - } -} - -func TestNewSessionTokenUnique(t *testing.T) { - seen := make(map[string]bool) - for i := 0; i < 1000; i++ { - raw, _, err := newSessionToken() - if err != nil { - t.Fatalf("newSessionToken: %v", err) - } - if seen[raw] { - t.Fatal("duplicate session token generated") - } - seen[raw] = true - } -} - -func TestDeriveSessionKey(t *testing.T) { - if deriveSessionKey("") != nil { - t.Fatal("empty secret must yield a nil key (feature disabled)") - } - if got := len(deriveSessionKey("x")); got != 32 { - t.Fatalf("derived key length: got %d, want 32 (AES-256)", got) - } -} - -func TestDeriveSiteOrigin(t *testing.T) { - cases := []struct{ in, want string }{ - {"https://drop.example.net/oauth/callback", "https://drop.example.net"}, - {"http://localhost:5173/oauth/callback", "http://localhost:5173"}, - {"", ""}, - {"not a url", ""}, - } - for _, c := range cases { - if got := deriveSiteOrigin(c.in); got != c.want { - t.Errorf("deriveSiteOrigin(%q) = %q, want %q", c.in, got, c.want) - } - } -} diff --git a/internal/httpapi/sessions.go b/internal/httpapi/sessions.go index d1f25eb..3389db0 100644 --- a/internal/httpapi/sessions.go +++ b/internal/httpapi/sessions.go @@ -1,11 +1,8 @@ package httpapi import ( - "context" - "encoding/json" "log/slog" "net/http" - "time" "github.com/go-chi/chi/v5" @@ -13,206 +10,197 @@ import ( "commilitia.net/cdrop/internal/jwtauth" ) -// Session management (AUTH.md §1, §6): list the caller's logins with their -// capability level, and really revoke one (delete the row → its access token can -// no longer refresh and dies within its short TTL). Revocation is a sensitive -// action, so it requires a recent step-up. A standalone step-up endpoint lets the -// UI establish that re-auth once (per session/device) and reuse it across actions -// within the window — the same session isn't re-prompted repeatedly (#3). +// Session management. After the unified-session-model rework, every logged-in client — +// browser, desktop, QR-paired device — is one delegated device session in the broker. The +// broker (R1: GET /internal/sessions) is the single authoritative device list; cdrop no +// longer keeps a parallel authoritative session table. The local `devices` rows survive only +// as a type/presence cache: they supply each device's type for the list overlay and back the +// real-time presence view (which is cdrop's domain, keyed by device name). Listing reads R1 +// and overlays type/online/current; revoking calls the broker (the session's source of truth) +// then drops the local cache row. -type stepUpReq struct { - Code string `json:"code"` - Verifier string `json:"verifier"` -} - -// handleStepUp exchanges a fresh prompt=login code and, on success, stamps the -// caller's session as stepped-up (per-session, cookie-keyed). Sensitive actions -// within StepUpMaxAgeSeconds then skip re-auth. 403 on failure; 204 when step-up -// is disabled (nothing to establish). -func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) { - if !s.cfg.StepUpEnabled { - w.WriteHeader(http.StatusNoContent) - return - } - claims, _ := jwtauth.ClaimsFromContext(r.Context()) - var body stepUpReq - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if !s.verifyStepUp(r, claims.UserID, body.Code, body.Verifier) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) - return - } - s.recordStepUp(r) - w.WriteHeader(http.StatusNoContent) +// requireFullSession rejects restricted guest sessions (scan-login borrow): they can +// transfer files but not manage devices, approve other devices, or revoke sessions. +// A full / dev session passes (AUTH Broker scope tier — Claims.Guest reads X-Auth-Scope). +func requireFullSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := jwtauth.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"}) + return + } + if claims.Guest() { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"}) + return + } + next.ServeHTTP(w, r) + }) } type sessionView struct { - ID string `json:"id"` + ID string `json:"id"` // device_id (the revoke handle exposed to the client) + DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` - Kind string `json:"kind"` // oidc | self | guest | macos | windows | linux | ios + Kind string `json:"kind"` // device type: browser | macos | windows | linux | ios Scope string `json:"scope"` // full | guest Current bool `json:"current"` - Online bool `json:"online"` // device currently connected (SSE) — UI sorts online first - // Native marks a device that authenticates with an IdP token and keeps no - // web_session row (desktop / iOS). It is surfaced from the devices registry so - // the session list covers every device; its "logout" routes to the device - // endpoint rather than the web-session one. - Native bool `json:"native"` - CreatedAt int64 `json:"created_at"` - LastUsedAt int64 `json:"last_used_at"` - ExpiresAt int64 `json:"expires_at"` + Online bool `json:"online"` + CreatedAt int64 `json:"created_at"` + LastUsedAt int64 `json:"last_used_at"` } -// isNativeDeviceType reports whether a device type is a real native client (one -// that authenticates via the IdP and keeps no web_session). browser devices are -// expected to carry a web_session; "shortcut" tokens have their own panel. -func isNativeDeviceType(t string) bool { - switch t { - case "macos", "windows", "linux", "ios": - return true - default: - return false - } -} - -// handleSessionsList returns the caller's login sessions with their scope, so the -// UI can show the permission level (完整 / 受限访客) and offer real logout. The list -// is unified: web_sessions (browser / scan-login) PLUS native client devices -// (desktop / iOS) that have no web_session of their own — so every logged-in -// device appears in one place and orphaned device rows fall away (AUTH.md §4). +// handleSessionsList returns the caller's logged-in devices with their permission level +// (完整 / 受限访客) and live online flag, so the UI can show each and offer logout. The +// authoritative list is the broker's delegated device sessions (R1); cdrop overlays the +// device type (local cache), the online dot (hub presence, keyed by device name), and the +// "current" flag (the session whose meta is this request's X-Auth-Meta). func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) - now := time.Now().Unix() - rows, err := s.queries.ListWebSessionsByUser(r.Context(), claims.UserID) + sessions, err := s.broker.ListSessions(r.Context(), claims.UserID) if err != nil { slog.Error("list sessions failed", "err", err, "user", claims.UserID) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "broker"}) return } - current := "" - if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { - current = sessionID(c.Value) - } - // Cookieless callers (native apps) can't match by cookie; fall back to their - // own device name to mark the "current" row. - callerDevice, _ := jwtauth.DeviceNameFromContext(r.Context()) - out := make([]sessionView, 0, len(rows)) - named := make(map[string]struct{}, len(rows)) - for _, row := range rows { - if row.ExpiresAt < now { - continue // hide expired sessions (reaped separately) + // device_id -> cached type, for the type overlay (a missing cache falls back to + // "browser"). The cache is bounded by the background device sweeper (last_seen TTL); a + // logged-out device's row is reaped there, not on this read path — this GET stays + // side-effect-free, and the session list itself is always R1-authoritative regardless of + // any stale cache row (the row only ever supplies a type for a device that is in R1). + typeByID := map[string]string{} + if devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID); err == nil { + for _, d := range devs { + typeByID[d.DeviceID] = d.Type } - if row.DeviceName != "" { - named[row.DeviceName] = struct{}{} + } + + out := make([]sessionView, 0, len(sessions)) + for _, sess := range sessions { + // A meta-less session is a non-cdrop-managed machine session — a desktop + // device-authorize bootstrap that the desktop replaces via 代铸 right away. It has + // no device_id, so it isn't a managed device and must not show as a phantom row. + if sess.Meta == "" { + continue + } + typ := typeByID[sess.Meta] + if typ == "" { + typ = "browser" + } + scope := "full" + if jwtauth.ScopeTier(sess.Scope) == "guest" { + scope = "guest" } out = append(out, sessionView{ - ID: row.ID, - DeviceName: row.DeviceName, - Kind: row.Kind, - Scope: row.Scope, - Current: row.ID == current || (current == "" && row.DeviceName != "" && row.DeviceName == callerDevice), - Online: row.DeviceName != "" && s.hub.Online(claims.UserID, row.DeviceName), - CreatedAt: row.CreatedAt, - LastUsedAt: row.LastUsedAt, - ExpiresAt: row.ExpiresAt, + ID: sess.Meta, + DeviceID: sess.Meta, + DeviceName: sess.Label, + Kind: typ, + Scope: scope, + Current: sess.Meta == claims.DeviceID, + Online: s.hub.Online(claims.UserID, sess.Label), + CreatedAt: sess.CreatedAt, + LastUsedAt: sess.LastUsedAt, }) } - // Native clients keep no web_session — surface them from the devices registry, - // skipping any whose name already has a web_session (no double-listing). - if devs, derr := s.queries.ListDevicesByUser(r.Context(), claims.UserID); derr == nil { - for _, d := range devs { - if !isNativeDeviceType(d.Type) { - continue - } - if _, ok := named[d.Name]; ok { - continue - } - out = append(out, sessionView{ - ID: "device:" + d.Name, - DeviceName: d.Name, - Kind: d.Type, - Scope: "full", - Native: true, - Current: current == "" && d.Name != "" && d.Name == callerDevice, - Online: s.hub.Online(claims.UserID, d.Name), - CreatedAt: d.LastSeen, - LastUsedAt: d.LastSeen, - ExpiresAt: 0, - }) - } - } - writeJSON(w, http.StatusOK, map[string]any{"sessions": out}) } -// deviceNameHasLiveSession reports whether the user still has a non-expired -// web_session bound to deviceName — used to avoid deleting a device that another -// live session (e.g. a re-login that left the old row) still depends on. -func (s *Server) deviceNameHasLiveSession(ctx context.Context, userID, deviceName string) bool { - rows, err := s.queries.ListWebSessionsByUser(ctx, userID) - if err != nil { - return false - } - now := time.Now().Unix() - for _, ws := range rows { - if ws.DeviceName == deviceName && ws.ExpiresAt >= now { - return true - } - } - return false -} - -// handleSessionRevoke deletes a session row (scoped to its owner), really -// invalidating that login. Requires a recent step-up (403 step_up_required -// otherwise). Kicks the device's live SSE so it drops immediately. +// handleSessionRevoke logs out a device by id (= device_id): it resolves the device's broker +// session, revokes it, and drops the local cache row. Full session required (route-gated). func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) { - if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) - return - } claims, _ := jwtauth.ClaimsFromContext(r.Context()) id := chi.URLParam(r, "id") if id == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"}) return } - // Read the device name before deletion so we can kick its SSE. - deviceName := "" - if sess, err := s.queries.GetWebSession(r.Context(), id); err == nil && sess.UserID == claims.UserID { - deviceName = sess.DeviceName - } - n, err := s.queries.DeleteWebSessionForUser(r.Context(), db.DeleteWebSessionForUserParams{ - ID: id, - UserID: claims.UserID, - }) - if err != nil { - slog.Error("revoke session failed", "err", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + status, ok := s.revokeDevice(r, claims.UserID, id) + if !ok { + writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)}) return } - if n == 0 { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) - return - } - if deviceName != "" { - // Drop the device registration too, so a revoked device disappears from the - // device list at once (we no longer remove devices by hand) — unless another - // live session still uses this name. - if !s.deviceNameHasLiveSession(r.Context(), claims.UserID, deviceName) { - if _, derr := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ - UserID: claims.UserID, - Name: deviceName, - }); derr != nil { - slog.Warn("delete device on session revoke failed", "err", derr) - } - } - s.hub.Kick(claims.UserID, deviceName) - s.hub.PublishPresence(r.Context(), claims.UserID) - } w.WriteHeader(http.StatusNoContent) } + +// revokeDevice tears down a device's broker session and local cache row, then kicks its live +// SSE and re-broadcasts presence. Returns the HTTP status to report and whether it succeeded; +// the caller writes the response. Shared by device deletion, session revocation, and logout +// (they are the same operation). The broker session id is resolved cache-first (the local row +// holds the stable broker_sid) and falls back to R1 — the authoritative list — so a missing or +// pruned cache row still revokes correctly and stays authorized to this user. +func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bool) { + sid, name := "", "" + if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID { + sid = dev.BrokerSid + name = dev.Name + } + if sid == "" { + sessions, err := s.broker.ListSessions(r.Context(), userID) + if err != nil { + slog.Error("revoke: list sessions failed", "err", err, "user", userID) + return http.StatusBadGateway, false + } + for _, sess := range sessions { + if sess.Meta == deviceID { + sid = sess.SID + name = sess.Label + break + } + } + } + if sid == "" { + // Not this user's device, or already gone from both the cache and the broker. + return http.StatusNotFound, false + } + // Revoke the broker session first so the device can't refresh; a 404 (already gone) is + // idempotent success inside RevokeSession. + if err := s.broker.RevokeSession(r.Context(), sid); err != nil { + slog.Error("broker revoke failed", "err", err, "user", userID, "sid", sid) + return http.StatusBadGateway, false + } + if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ + DeviceID: deviceID, + UserID: userID, + }); err != nil { + // The session is already revoked; a failed cache delete is non-fatal (the sweeper + // and the next list-prune clean it). Report success so the client sees the logout. + slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID) + } + if name != "" { + s.hub.Kick(userID, name) + } + s.hub.PublishPresence(r.Context(), userID) + return http.StatusNoContent, true +} + +// handleLogout logs out the calling device by revoking its own broker session (so it can no +// longer refresh) and dropping its cache row. The client also discards its tokens. A caller +// with no managed device (no X-Auth-Meta) just succeeds — there is nothing server-side to +// revoke. Origin-checked for CSRF. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if !s.sameOrigin(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) + return + } + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + if claims.DeviceID != "" { + if status, ok := s.revokeDevice(r, claims.UserID, claims.DeviceID); !ok && status != http.StatusNotFound { + slog.Warn("logout revoke failed", "status", status, "user", claims.UserID) + } + } + w.WriteHeader(http.StatusNoContent) +} + +func revokeErrorMsg(status int) string { + switch status { + case http.StatusNotFound: + return "device not found" + case http.StatusBadGateway: + return "revoke failed" + default: + return "db" + } +} diff --git a/internal/httpapi/shortcut.go b/internal/httpapi/shortcut.go deleted file mode 100644 index b37fd19..0000000 --- a/internal/httpapi/shortcut.go +++ /dev/null @@ -1,260 +0,0 @@ -package httpapi - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" - - "commilitia.net/cdrop/internal/db" - "commilitia.net/cdrop/internal/jwtauth" -) - -// shortcutScope is the single scope a shortcut token is granted; the route layer -// only lets a token carrying it reach the clipboard endpoints (see server.go). -const shortcutScope = "clipboard" - -// requireScope gates a route group that scoped shortcut tokens may also reach: a -// scoped token must carry the named scope, while a full login session always -// passes (it has no scope restriction). -func requireScope(scope string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - claims, ok := jwtauth.ClaimsFromContext(r.Context()) - if !ok { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) - return - } - if claims.Scoped() && !claims.HasScope(scope) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient scope"}) - return - } - next.ServeHTTP(w, r) - }) - } -} - -// rejectScoped blocks scoped shortcut tokens outright — for every route only a -// full login session may touch. A leaked clipboard token thus reaches nothing -// here: not the device list, transfers, signalling, nor token self-management. -func rejectScoped(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - claims, ok := jwtauth.ClaimsFromContext(r.Context()) - if !ok { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) - return - } - if claims.Scoped() { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "session token required"}) - return - } - next.ServeHTTP(w, r) - }) -} - -// requireFullSession rejects restricted guest sessions (scan-login borrow): they -// may transfer files but not reach account-management surfaces — removing devices, -// approving more devices, or minting / listing / revoking long-lived tokens. A -// borrowed device thus can't escalate. Full / OIDC / dev sessions pass; scoped -// shortcut tokens are already blocked upstream by rejectScoped (AUTH.md §3.2). -func requireFullSession(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - claims, ok := jwtauth.ClaimsFromContext(r.Context()) - if !ok { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) - return - } - if claims.Guest() { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"}) - return - } - next.ServeHTTP(w, r) - }) -} - -// 这些端点只由登录会话调用(路由层 rejectScoped 守门)——用 token 不能自管 token, -// 防止泄漏的快捷指令 token 自我续期或越权。 - -type shortcutIssueReq struct { - Label string `json:"label"` -} - -type shortcutIssueResp struct { - // Token 仅在签发时返回这一次,服务端不留可还原副本(只存 jti 等元数据)。 - Token string `json:"token"` - Jti string `json:"jti"` - Label string `json:"label"` - Scopes []string `json:"scopes"` - ExpiresAt int64 `json:"expires_at"` -} - -type shortcutTokenView struct { - Jti string `json:"jti"` - Label string `json:"label"` - Scopes []string `json:"scopes"` - CreatedAt int64 `json:"created_at"` - ExpiresAt int64 `json:"expires_at"` - LastUsedAt *int64 `json:"last_used_at"` - Revoked bool `json:"revoked"` -} - -// handleShortcutIssue mints a long-lived, clipboard-scoped HS256 token, stores -// its metadata, and returns the token string once. Disabled (503) when no HS256 -// secret is configured. -func (s *Server) handleShortcutIssue(w http.ResponseWriter, r *http.Request) { - if len(s.cfg.HS256Secret) == 0 { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "shortcut tokens not enabled"}) - return - } - claims, _ := jwtauth.ClaimsFromContext(r.Context()) - - var req shortcutIssueReq - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - label := strings.TrimSpace(req.Label) - if label == "" { - label = "iOS Shortcut" - } - if len(label) > 64 { - label = label[:64] - } - - now := time.Now() - active, err := s.queries.CountActiveShortcutTokensByUser(r.Context(), db.CountActiveShortcutTokensByUserParams{ - UserID: claims.UserID, - ExpiresAt: now.Unix(), - }) - if err != nil { - slog.Error("count shortcut tokens failed", "err", err, "user", claims.UserID) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) - return - } - if active >= int64(s.cfg.ShortcutMaxPerUser) { - writeJSON(w, http.StatusConflict, map[string]string{"error": "too many active tokens"}) - return - } - - jti, err := randomTokenID() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) - return - } - expires := now.Add(time.Duration(s.cfg.ShortcutTokenTTLDays) * 24 * time.Hour) - - token, err := signShortcutToken(s.cfg.HS256Secret, claims.UserID, jti, shortcutScope, now, expires) - if err != nil { - slog.Error("sign shortcut token failed", "err", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "sign"}) - return - } - - if err := s.queries.InsertShortcutToken(r.Context(), db.InsertShortcutTokenParams{ - Jti: jti, - UserID: claims.UserID, - Label: label, - Scopes: shortcutScope, - CreatedAt: now.Unix(), - ExpiresAt: expires.Unix(), - }); err != nil { - slog.Error("insert shortcut token failed", "err", err, "user", claims.UserID) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) - return - } - - writeJSON(w, http.StatusCreated, shortcutIssueResp{ - Token: token, - Jti: jti, - Label: label, - Scopes: []string{shortcutScope}, - ExpiresAt: expires.Unix(), - }) -} - -// handleShortcutList returns the caller's tokens (metadata only, never the token -// string). -func (s *Server) handleShortcutList(w http.ResponseWriter, r *http.Request) { - claims, _ := jwtauth.ClaimsFromContext(r.Context()) - rows, err := s.queries.ListShortcutTokensByUser(r.Context(), claims.UserID) - if err != nil { - slog.Error("list shortcut tokens failed", "err", err, "user", claims.UserID) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) - return - } - out := make([]shortcutTokenView, 0, len(rows)) - for _, t := range rows { - out = append(out, shortcutTokenView{ - Jti: t.Jti, - Label: t.Label, - Scopes: strings.Fields(t.Scopes), - CreatedAt: t.CreatedAt, - ExpiresAt: t.ExpiresAt, - LastUsedAt: t.LastUsedAt, - Revoked: t.Revoked != 0, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"tokens": out}) -} - -// handleShortcutRevoke flips the revoked flag (scoped to the caller's user_id so -// one user can't revoke another's). verifyHS256 reads the flag on every request, -// so revocation takes effect on the token's next use. -func (s *Server) handleShortcutRevoke(w http.ResponseWriter, r *http.Request) { - claims, _ := jwtauth.ClaimsFromContext(r.Context()) - jti := chi.URLParam(r, "jti") - if jti == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing jti"}) - return - } - n, err := s.queries.RevokeShortcutToken(r.Context(), db.RevokeShortcutTokenParams{ - Jti: jti, - UserID: claims.UserID, - }) - if err != nil { - slog.Error("revoke shortcut token failed", "err", err, "user", claims.UserID) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) - return - } - if n == 0 { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// signShortcutToken mints a compact HS256 JWT: sub=userID, jti, scope (space- -// delimited OAuth-style, informational — the store row is authoritative), iat, -// exp. The shared HS256 secret is the same one verifyHS256 checks against. -func signShortcutToken(secret, userID, jti, scope string, iat, exp time.Time) (string, error) { - sig, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.HS256, Key: jwtauth.DeriveHS256Key(secret)}, - (&jose.SignerOptions{}).WithType("JWT"), - ) - if err != nil { - return "", err - } - std := jwt.Claims{ - Subject: userID, - ID: jti, - IssuedAt: jwt.NewNumericDate(iat), - Expiry: jwt.NewNumericDate(exp), - } - return jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize() -} - -// randomTokenID returns a 128-bit URL-safe random id for the jti. -func randomTokenID() (string, error) { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b[:]), nil -} diff --git a/internal/httpapi/sse.go b/internal/httpapi/sse.go index dbe8aff..b1e2dcf 100644 --- a/internal/httpapi/sse.go +++ b/internal/httpapi/sse.go @@ -38,6 +38,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { http.Error(w, "missing device", http.StatusBadRequest) return } + deviceType := jwtauth.DeviceTypeFromContext(r.Context()) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -50,7 +51,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { } flusher.Flush() - client := s.hub.Connect(r.Context(), claims.UserID, deviceName) + client := s.hub.Connect(r.Context(), claims.UserID, deviceName, deviceType) defer s.hub.Disconnect(client) slog.Debug("sse connected", "user", claims.UserID, "device", deviceName) @@ -74,14 +75,15 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { } flusher.Flush() // Refresh last_seen so a connection-only client (no other API calls) - // keeps its device row alive past TTL — matches the sliding - // refresh_token semantics requested for device persistence. - _ = s.queries.UpsertDevice(r.Context(), db.UpsertDeviceParams{ - UserID: claims.UserID, - Name: deviceName, - Type: jwtauth.DeviceTypeFromContext(r.Context()), - LastSeen: time.Now().Unix(), - }) + // keeps its managed device row alive past the sweeper TTL while connected. + if claims.DeviceID != "" { + _ = s.queries.TouchDevice(r.Context(), db.TouchDeviceParams{ + LastSeen: time.Now().Unix(), + Tier: claims.Tier(), + DeviceID: claims.DeviceID, + UserID: claims.UserID, + }) + } case <-r.Context().Done(): return } diff --git a/internal/hub/hub.go b/internal/hub/hub.go index a3000cf..af7fd96 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -37,7 +37,11 @@ const clientBuffer = 32 type Client struct { UserID string DeviceID string - ch chan Event + // Type is the client-declared device type (browser / macos / windows / linux / ios). + // It lets presence label a live device that has no devices-table row (a global-SSO + // browser or a broker-authenticated desktop). + Type string + ch chan Event } func (c *Client) Events() <-chan Event { return c.ch } @@ -67,10 +71,13 @@ func New(devices DeviceLister) *Hub { // Connect registers a new SSE client and announces presence to the user's other devices. // If a client for (userID, deviceID) already exists (e.g., a tab refresh), its channel is closed. -func (h *Hub) Connect(ctx context.Context, userID, deviceID string) *Client { +// deviceType is the caller's declared device type, surfaced in presence for a live device +// that has no devices-table row. +func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceType string) *Client { c := &Client{ UserID: userID, DeviceID: deviceID, + Type: deviceType, ch: make(chan Event, clientBuffer), } @@ -115,10 +122,15 @@ func (h *Hub) Disconnect(c *Client) { // SendTo routes an event to a specific (userID, deviceID). Reports whether // the target was online and the event was queued. +// +// The send happens while still holding the read lock so it can never race a Kick / Connect- +// replace / Close that closes the channel (those hold the write lock): a send on a closed +// channel panics even inside a select, so close-vs-send must be mutually exclusive. The send +// is non-blocking (select default), so holding the read lock across it is brief. func (h *Hub) SendTo(userID, deviceID string, ev Event) bool { h.mu.RLock() + defer h.mu.RUnlock() c, ok := h.users[userID][deviceID] - h.mu.RUnlock() if !ok { return false } @@ -132,10 +144,12 @@ func (h *Hub) SendTo(userID, deviceID string, ev Event) bool { } } -// Broadcast fans an event out to every live client of a user. +// Broadcast fans an event out to every live client of a user. The non-blocking sends run +// under the read lock so they can't race a concurrent channel close (see SendTo). func (h *Hub) Broadcast(userID string, ev Event) { - clients := h.snapshotClients(userID) - for _, c := range clients { + h.mu.RLock() + defer h.mu.RUnlock() + for _, c := range h.users[userID] { select { case c.ch <- ev: default: @@ -153,20 +167,20 @@ func (h *Hub) Online(userID, deviceID string) bool { return ok } -// Kick force-removes a (userID, deviceID) entry from the hub and closes its -// event channel; the SSE handler exits on the next iteration. 与 Connect 的 -// 旧通道关闭模式一致(与并发 SendTo 之间存在极窄竞态,但 Kick 罕用,可接受)。 +// Kick force-removes a (userID, deviceID) entry from the hub and closes its event channel; +// the SSE handler exits on the next iteration. The close happens UNDER the write lock — the +// same discipline as Connect-replace and Close — so it can never race a send from +// publishPresence / Broadcast / SendTo (those hold the read lock), which would otherwise +// panic on a send to a closed channel. revokeDevice now Kicks on every logout / device +// delete / session revoke, so this path is hot, not rare. func (h *Hub) Kick(userID, deviceID string) { h.mu.Lock() - c, ok := h.users[userID][deviceID] - if ok { + defer h.mu.Unlock() + if c, ok := h.users[userID][deviceID]; ok { delete(h.users[userID], deviceID) if len(h.users[userID]) == 0 { delete(h.users, userID) } - } - h.mu.Unlock() - if ok { close(c.ch) } } @@ -194,44 +208,47 @@ func (h *Hub) Close() { h.users = map[string]map[string]*Client{} } -func (h *Hub) snapshotClients(userID string) []*Client { - h.mu.RLock() - defer h.mu.RUnlock() - src := h.users[userID] - out := make([]*Client, 0, len(src)) - for _, c := range src { - out = append(out, c) - } - return out -} - func (h *Hub) publishPresence(ctx context.Context, userID string) { devs, err := h.devices.ListDevicesByUser(ctx, userID) if err != nil { + // Log but continue with an empty managed-device set: a transient DB error must + // not blank out the presence of live, unmanaged devices that need no row. slog.Error("presence: list devices failed", "user", userID, "err", err) - return } + now := time.Now().Unix() + // Hold the read lock across the build AND the sends: the non-blocking sends below must + // be mutually exclusive with any channel close (Kick / Connect-replace / Close hold the + // write lock), or a send could hit a closed channel and panic. h.mu.RLock() + defer h.mu.RUnlock() live := h.users[userID] - items := make([]PresenceDevice, 0, len(devs)) + seen := make(map[string]bool, len(devs)) + items := make([]PresenceDevice, 0, len(devs)+len(live)) for _, d := range devs { _, online := live[d.Name] items = append(items, PresenceDevice{ Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen, }) + seen[d.Name] = true } - clients := make([]*Client, 0, len(live)) - for _, c := range live { - clients = append(clients, c) + // Live connections without a devices-table row — a global-SSO browser before 代铸, or a + // device whose cache row hasn't landed yet. They are reachable on the hub (clipboard / + // signaling already route to them), so they must appear as online peers too. + for name, c := range live { + if seen[name] { + continue + } + items = append(items, PresenceDevice{ + Name: name, Type: c.Type, Online: true, LastSeen: now, + }) } - h.mu.RUnlock() ev := Event{ Type: "presence", Data: map[string]any{"devices": items}, } - for _, c := range clients { + for _, c := range live { select { case c.ch <- ev: default: diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index 8774476..6d0d2ba 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -2,6 +2,8 @@ package hub import ( "context" + "fmt" + "sync" "sync/atomic" "testing" "time" @@ -44,7 +46,7 @@ func TestConnectFiresPresenceEvent(t *testing.T) { h := New(lister) defer h.Close() - c := h.Connect(context.Background(), "alice", "tab-1") + c := h.Connect(context.Background(), "alice", "tab-1", "browser") defer h.Disconnect(c) ev := waitForEvent(t, c, "presence", time.Second) @@ -65,11 +67,11 @@ func TestSecondClientSeesFirstAsOnline(t *testing.T) { h := New(lister) defer h.Close() - c1 := h.Connect(context.Background(), "alice", "tab-1") + c1 := h.Connect(context.Background(), "alice", "tab-1", "browser") defer h.Disconnect(c1) _ = waitForEvent(t, c1, "presence", time.Second) - c2 := h.Connect(context.Background(), "alice", "tab-2") + c2 := h.Connect(context.Background(), "alice", "tab-2", "browser") defer h.Disconnect(c2) // c2 receives its own presence (announced on Connect). @@ -94,12 +96,28 @@ func TestSecondClientSeesFirstAsOnline(t *testing.T) { } } +// A live device with no devices-table row (global-SSO browser / broker-auth desktop) +// must still appear in presence as online, labelled by its declared type. +func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) { + h := New(&fakeLister{}) // empty managed-device set + defer h.Close() + + c := h.Connect(context.Background(), "alice", "macbook", "macos") + defer h.Disconnect(c) + + ev := waitForEvent(t, c, "presence", time.Second) + devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice) + if len(devs) != 1 || devs[0].Name != "macbook" || devs[0].Type != "macos" || !devs[0].Online { + t.Errorf("live-only device should appear online in presence: %+v", devs) + } +} + func TestSendToRoutesEvent(t *testing.T) { lister := &fakeLister{} h := New(lister) defer h.Close() - c := h.Connect(context.Background(), "alice", "tab-1") + c := h.Connect(context.Background(), "alice", "tab-1", "browser") defer h.Disconnect(c) _ = waitForEvent(t, c, "presence", time.Second) @@ -124,11 +142,11 @@ func TestReconnectClosesOldChannel(t *testing.T) { h := New(&fakeLister{}) defer h.Close() - old := h.Connect(context.Background(), "alice", "tab-1") + old := h.Connect(context.Background(), "alice", "tab-1", "browser") // drain the initial presence so we can detect close <-old.Events() - _ = h.Connect(context.Background(), "alice", "tab-1") + _ = h.Connect(context.Background(), "alice", "tab-1", "browser") select { case _, ok := <-old.Events(): @@ -146,7 +164,7 @@ func TestDisconnectRemovesFromOnlineSet(t *testing.T) { }) defer h.Close() - c := h.Connect(context.Background(), "alice", "tab-1") + c := h.Connect(context.Background(), "alice", "tab-1", "browser") if !h.Online("alice", "tab-1") { t.Fatal("client should be online after Connect") } @@ -156,6 +174,36 @@ func TestDisconnectRemovesFromOnlineSet(t *testing.T) { } } +// TestConcurrentKickAndPresenceNoPanic hammers Kick (which closes channels under the write +// lock) against publishPresence / Broadcast / SendTo (non-blocking sends under the read lock). +// Before the close-vs-send fix this raced into a "send on closed channel" panic that crashed +// the whole process; now close and send are mutually exclusive. Run with -race. +func TestConcurrentKickAndPresenceNoPanic(t *testing.T) { + h := New(&fakeLister{ + devices: []db.Device{{UserID: "u", Name: "d", Type: "browser"}}, + }) + defer h.Close() + + const workers = 16 + const iters = 200 + var wg sync.WaitGroup + for i := 0; i < workers; i += 1 { + wg.Add(1) + go func(n int) { + defer wg.Done() + name := fmt.Sprintf("d%d", n) + for j := 0; j < iters; j += 1 { + h.Connect(context.Background(), "u", name, "browser") + h.PublishPresence(context.Background(), "u") + h.Broadcast("u", Event{Type: "x"}) + h.SendTo("u", name, Event{Type: "y"}) + h.Kick("u", name) + } + }(i) + } + wg.Wait() +} + // TestGracePeriod uses a tiny grace so the test runs fast. func TestGracePeriodDelaysOfflinePresence(t *testing.T) { h := New(&fakeLister{ @@ -167,9 +215,9 @@ func TestGracePeriodDelaysOfflinePresence(t *testing.T) { h.grace = 100 * time.Millisecond defer h.Close() - c1 := h.Connect(context.Background(), "alice", "tab-1") + c1 := h.Connect(context.Background(), "alice", "tab-1", "browser") defer h.Disconnect(c1) - c2 := h.Connect(context.Background(), "alice", "tab-2") + c2 := h.Connect(context.Background(), "alice", "tab-2", "browser") // Drain initial presence frames _ = waitForEvent(t, c1, "presence", time.Second) _ = waitForEvent(t, c1, "presence", time.Second) diff --git a/internal/jwtauth/claims.go b/internal/jwtauth/claims.go index 07878d9..e9d4a21 100644 --- a/internal/jwtauth/claims.go +++ b/internal/jwtauth/claims.go @@ -1,41 +1,47 @@ package jwtauth -import "context" +import ( + "context" + "strings" +) +// Claims is the authenticated identity for a request. In prod it is sourced from the +// Auth Broker at the edge: broker /verify authenticates the caller and injects X-Auth-* +// headers this process trusts (Caddy strips any client-supplied X-Auth-* at the trust +// boundary, so only the broker can set them). In dev it is synthesised from the dev token. type Claims struct { - UserID string - Groups []string - // JTI and Scopes are populated only for HS256 shortcut tokens; a full OIDC / - // dev session leaves them empty. A non-empty JTI marks a *scoped* token — - // one allowed to reach only the endpoints its Scopes grant (the route layer - // enforces this). This keeps a leaked shortcut token's blast radius minimal. - JTI string - Scopes []string - // SessionScope is set only for cdrop self-signed session tokens (scan-login): - // "full" or "guest". Empty for OIDC / dev sessions and scoped shortcut tokens. - // A "guest" session is capability-limited (requireFullSession rejects it on - // account-management routes) even though it is not Scoped(). - SessionScope string + UserID string // X-Auth-Subject (Casdoor sub) + Name string // X-Auth-Name (display name); may be empty + Avatar string // X-Auth-Avatar (profile picture URL from the broker account); may be empty + Groups []string // X-Auth-Roles, comma-split + // Scope is the raw X-Auth-Scope: a global SSO user is "full"; a cdrop delegated + // session is "app:cdrop:". Tier() reads the capability grade off the end. + Scope string + // DeviceID is X-Auth-Meta: the cdrop device_id this session was minted for — the + // join key to the devices row. Empty for an unmanaged caller (e.g. a global SSO + // browser that never paired through cdrop). + DeviceID string } -// Scoped reports whether these claims came from a scoped shortcut token rather -// than a full login session. -func (c *Claims) Scoped() bool { return c.JTI != "" } +// ScopeTier returns the capability grade — the last colon-separated segment of a broker +// scope ("app:cdrop:guest" → "guest", "full" → "full"). A tierless scope is its own tier. +// Shared by Claims.Tier() and the session-list overlay so the two never diverge. +func ScopeTier(scope string) string { + if i := strings.LastIndex(scope, ":"); i >= 0 { + return scope[i+1:] + } + return scope +} + +// Tier returns the capability grade off the caller's scope (see ScopeTier). +func (c *Claims) Tier() string { + return ScopeTier(c.Scope) +} // Guest reports whether these claims came from a restricted guest session (a -// scan-login borrow). Guest sessions can transfer files but not manage the -// account, approve other devices, or mint long-lived tokens. -func (c *Claims) Guest() bool { return c.SessionScope == "guest" } - -// HasScope reports whether the claims grant the named scope. -func (c *Claims) HasScope(scope string) bool { - for _, s := range c.Scopes { - if s == scope { - return true - } - } - return false -} +// scan-login borrow). Guest sessions can transfer files but not manage devices, +// approve other devices, or mint long-lived tokens. +func (c *Claims) Guest() bool { return c.Tier() == "guest" } type ctxKey int @@ -50,9 +56,9 @@ func ClaimsFromContext(ctx context.Context) (*Claims, bool) { return c, ok } -// ContextWithClaims attaches claims to a context — the inverse of -// ClaimsFromContext. The auth middleware uses this; it is also the seam handlers -// and tests use to inject claims directly. +// ContextWithClaims attaches claims to a context — the inverse of ClaimsFromContext. +// The auth middleware uses this; it is also the seam handlers and tests use to inject +// claims directly. func ContextWithClaims(ctx context.Context, c *Claims) context.Context { return context.WithValue(ctx, claimsCtxKey, c) } @@ -63,7 +69,7 @@ func DeviceNameFromContext(ctx context.Context) (string, bool) { } // DeviceTypeFromContext returns the client-declared device type set by the auth -// middleware (browser / macos / windows / linux), defaulting to "browser". +// middleware (browser / macos / windows / linux / ios), defaulting to "browser". func DeviceTypeFromContext(ctx context.Context) string { t, ok := ctx.Value(deviceTypeCtxKey).(string) if !ok || t == "" { diff --git a/internal/jwtauth/devices.go b/internal/jwtauth/devices.go index 4b283a5..a74b897 100644 --- a/internal/jwtauth/devices.go +++ b/internal/jwtauth/devices.go @@ -11,14 +11,11 @@ import ( // DeviceSweepInterval is how often the device sweeper wakes up. const DeviceSweepInterval = 1 * time.Hour -// RunDeviceSweeper prunes the devices table on a timer so it stays aligned with -// the session list (AUTH.md §4): it drops (1) devices whose last_seen is older -// than ttl — registration must not outlive the longest valid refresh_token -// (brief §2: 196h sliding window) — and (2) browser devices whose web_session is -// gone (revoked or expired), which would otherwise linger until the stale cutoff -// now that users can no longer remove devices by hand. Native and shortcut -// devices keep no web_session and are pruned by the stale cutoff only. ttl ≤ 0 -// disables the sweeper. +// RunDeviceSweeper prunes the devices table on a timer: it drops devices whose +// last_seen is older than ttl, so a registration never outlives the broker session's +// refresh window (a device idle past the window has no live broker session anyway). +// The session is the broker's source of truth; this only reaps stale local rows. ttl +// ≤ 0 disables the sweeper. func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) { if ttl <= 0 { slog.Info("device sweeper disabled (ttl <= 0)") @@ -33,14 +30,10 @@ func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duratio case <-ctx.Done(): return case <-t.C: - now := time.Now() - cutoff := now.Add(-ttl).Unix() + cutoff := time.Now().Add(-ttl).Unix() if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil { slog.Error("device sweeper failed", "err", err) } - if _, err := queries.DeleteOrphanBrowserDevices(ctx, now.Unix()); err != nil { - slog.Error("device sweeper: orphan browser cleanup failed", "err", err) - } } } } diff --git a/internal/jwtauth/devices_test.go b/internal/jwtauth/devices_test.go index 6791c5d..a760f17 100644 --- a/internal/jwtauth/devices_test.go +++ b/internal/jwtauth/devices_test.go @@ -31,15 +31,16 @@ func TestDeleteStaleDevices_RemovesOnlyOld(t *testing.T) { old := now.Add(-200 * time.Hour) fresh := now.Add(-1 * time.Hour) - upsert := func(name string, ts time.Time) { - if err := q.UpsertDevice(ctx, db.UpsertDeviceParams{ - UserID: "alice", Name: name, Type: "browser", LastSeen: ts.Unix(), + create := func(name string, ts time.Time) { + if err := q.CreateDevice(ctx, db.CreateDeviceParams{ + DeviceID: name, UserID: "alice", Name: name, Type: "browser", + Tier: "full", CreatedAt: ts.Unix(), LastSeen: ts.Unix(), }); err != nil { - t.Fatalf("upsert %s: %v", name, err) + t.Fatalf("create %s: %v", name, err) } } - upsert("old-device", old) - upsert("fresh-device", fresh) + create("old-device", old) + create("fresh-device", fresh) // Cutoff = "older than 196 hours from now" mimics RunDeviceSweeper math. cutoff := now.Add(-196 * time.Hour).Unix() diff --git a/internal/jwtauth/jwks.go b/internal/jwtauth/jwks.go deleted file mode 100644 index 40feb9a..0000000 --- a/internal/jwtauth/jwks.go +++ /dev/null @@ -1,113 +0,0 @@ -package jwtauth - -import ( - "context" - "crypto/rsa" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "sync" - "time" - - "github.com/go-jose/go-jose/v4" -) - -// jwksCache fetches & caches the Casdoor JWKS. -// -// Behavior: -// - successful fetch: refreshes the entire key set -// - kid miss: forces a single refresh attempt -// - stale-while-error: if cached entry exists, return it even when refresh fails -type jwksCache struct { - url string - ttl time.Duration - - mu sync.RWMutex - keys map[string]*rsa.PublicKey - fetchedAt time.Time - - httpClient *http.Client -} - -func newJWKSCache(url string, ttl time.Duration) *jwksCache { - return &jwksCache{ - url: url, - ttl: ttl, - keys: map[string]*rsa.PublicKey{}, - httpClient: &http.Client{Timeout: 5 * time.Second}, - } -} - -func (j *jwksCache) GetKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { - if kid == "" { - return nil, errors.New("missing kid") - } - - j.mu.RLock() - cached, found := j.keys[kid] - fresh := !j.fetchedAt.IsZero() && time.Since(j.fetchedAt) < j.ttl - j.mu.RUnlock() - - if found && fresh { - return cached, nil - } - - if err := j.refresh(ctx); err != nil { - if found { - slog.Warn("jwks refresh failed; returning stale key", - "err", err, "kid", kid) - return cached, nil - } - return nil, fmt.Errorf("jwks refresh: %w", err) - } - - j.mu.RLock() - defer j.mu.RUnlock() - if k, ok := j.keys[kid]; ok { - return k, nil - } - return nil, fmt.Errorf("kid %q not found in JWKS", kid) -} - -func (j *jwksCache) refresh(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, j.url, nil) - if err != nil { - return err - } - resp, err := j.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("status %d", resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - var set jose.JSONWebKeySet - if err := json.Unmarshal(body, &set); err != nil { - return fmt.Errorf("parse JWKS: %w", err) - } - - next := map[string]*rsa.PublicKey{} - for _, k := range set.Keys { - pk, ok := k.Key.(*rsa.PublicKey) - if !ok || k.KeyID == "" { - continue - } - next[k.KeyID] = pk - } - - j.mu.Lock() - defer j.mu.Unlock() - j.keys = next - j.fetchedAt = time.Now() - return nil -} diff --git a/internal/jwtauth/middleware.go b/internal/jwtauth/middleware.go index 0b657d4..7c6347b 100644 --- a/internal/jwtauth/middleware.go +++ b/internal/jwtauth/middleware.go @@ -2,354 +2,131 @@ package jwtauth import ( "context" - "crypto/sha256" "crypto/subtle" "encoding/json" "errors" - "fmt" "log/slog" "net/http" "strings" "time" - "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" - "commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/db" ) -// Store is the subset of *db.Queries the auth middleware uses: device upserts, -// the shortcut-token lookups needed to honour revocation, and the web_session -// lookup that makes a self-signed session token's revocation take effect at once -// (a deleted row → the token is rejected on its next request, not after TTL). -// Declared as an interface so tests can swap in a fake. +// Store is the subset of *db.Queries the auth middleware uses: refreshing a managed +// device's last_seen + tier on each request. Declared as an interface so tests can +// swap in a fake. type Store interface { - UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error - GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error) - TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error - GetWebSession(ctx context.Context, id string) (db.WebSession, error) + TouchDevice(ctx context.Context, arg db.TouchDeviceParams) error } +// Authenticator turns each request's identity into Claims. After the Auth Broker +// migration (path A) cdrop no longer verifies tokens itself: in prod the broker +// authenticates at the edge and injects X-Auth-* headers this process trusts; in dev +// the claims are synthesised from the dev token. type Authenticator struct { - cfg *config.Config - store Store - jwks *jwksCache - hsKey []byte - sessionTokenKey []byte + cfg *config.Config + store Store } func New(cfg *config.Config, store Store) *Authenticator { - a := &Authenticator{ - cfg: cfg, - store: store, - } - if cfg.HS256Secret != "" { - a.hsKey = DeriveHS256Key(cfg.HS256Secret) - } - // Self-signed session tokens (scan-login) are keyed off SessionSecret; nil - // when unset (dev) disables verifySelfToken, matching the minting side. - a.sessionTokenKey = DeriveSessionTokenKey(cfg.SessionSecret) - if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" { - a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute) - } - return a + return &Authenticator{cfg: cfg, store: store} } func (a *Authenticator) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, ok := bearerToken(r) - if !ok { - unauthorized(w, "missing bearer token") - return - } - - claims, err := a.verify(r.Context(), token, r) + claims, err := a.authenticate(r) if err != nil { slog.Warn("auth failed", "err", err, "path", r.URL.Path) - unauthorized(w, "invalid token") + unauthorized(w, "unauthorized") return } deviceName := SanitizeDeviceName(r.Header.Get("X-Device-Name")) deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type")) - if claims.Scoped() { - // The backend authoritatively knows a scoped token is the iOS - // Shortcut, so it labels the device "shortcut" regardless of any - // header. ("ios" stays reserved for a real native client.) - deviceType = "shortcut" - } - // Register a device only when the client names itself (X-Device-Name). - // A nameless request — e.g. a polling shortcut hitting /api/clipboard/version - // without the header — must NOT be registered: the old random UA fallback - // minted a fresh "Unknown Device" row on every request and flooded the list. - if deviceName != "" { - if err := a.store.UpsertDevice(r.Context(), db.UpsertDeviceParams{ - UserID: claims.UserID, - Name: deviceName, - Type: deviceType, + // Keep the managed device's last_seen + tier fresh. Identity is the + // broker-issued device_id (X-Auth-Meta); the row is created at scan-login + // collect, so this only ever updates — an unmanaged caller (no device_id, + // e.g. a global SSO browser) is skipped, and a missing row no-ops. + if claims.DeviceID != "" { + if err := a.store.TouchDevice(r.Context(), db.TouchDeviceParams{ LastSeen: time.Now().Unix(), + Tier: claims.Tier(), + DeviceID: claims.DeviceID, + UserID: claims.UserID, }); err != nil { - // non-fatal: log and continue so transient DB errors don't 401 users - slog.Error("device upsert failed", - "err", err, "user", claims.UserID, "device", deviceName) + // non-fatal: log and continue so a transient DB error doesn't 401 users + slog.Error("device touch failed", + "err", err, "user", claims.UserID, "device", claims.DeviceID) } } - ctx := context.WithValue(r.Context(), claimsCtxKey, claims) + ctx := ContextWithClaims(r.Context(), claims) ctx = context.WithValue(ctx, deviceCtxKey, deviceName) ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType) next.ServeHTTP(w, r.WithContext(ctx)) }) } -func (a *Authenticator) verify(ctx context.Context, token string, r *http.Request) (*Claims, error) { +// authenticate resolves the request identity. prod trusts the broker's edge-injected +// X-Auth-* headers; dev synthesises claims from the dev token. +func (a *Authenticator) authenticate(r *http.Request) (*Claims, error) { if a.cfg.AuthMode == "dev" { - return a.verifyDev(token, r) + return a.devClaims(r) } - // cdrop self-signed session tokens first: distinct key + typ=session, so a - // shortcut token (different key, requires jti) never validates here and a - // session token never falls through to the shortcut path's DB lookup. - if c, err := a.verifySelfToken(ctx, token); err == nil { - return c, nil + // prod: the request reached cdrop only by passing broker /verify at the edge, + // which injected these headers. Caddy strips any client-supplied X-Auth-* at the + // trust boundary, so their presence is the broker's say-so. No subject → the + // request did not authenticate. + sub := r.Header.Get("X-Auth-Subject") + if sub == "" { + return nil, errors.New("missing X-Auth-Subject (request did not pass broker /verify)") } - if c, err := a.verifyHS256(ctx, token); err == nil { - return c, nil - } - return a.verifyRS256(ctx, token) + return &Claims{ + UserID: sub, + Name: r.Header.Get("X-Auth-Name"), + Avatar: r.Header.Get("X-Auth-Avatar"), + Groups: splitRoles(r.Header.Get("X-Auth-Roles")), + Scope: r.Header.Get("X-Auth-Scope"), + DeviceID: r.Header.Get("X-Auth-Meta"), + }, nil } -// verifySelfToken validates a cdrop self-signed session access token (AUTH.md -// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest -// scope. This is the unified browser token — scan-login (self/guest) AND OIDC web -// logins both ride it now, so the browser holds one token type. After the cheap -// signature/exp/scope checks it does ONE indexed lookup of the token's sid against -// web_sessions: a deleted row means the session was revoked, and the token is -// rejected on its very next request (immediate "log out this device"). The IdP -// RS256 path (verifyRS256) remains only for the desktop client's loopback tokens. -func (a *Authenticator) verifySelfToken(ctx context.Context, token string) (*Claims, error) { - if len(a.sessionTokenKey) == 0 { - return nil, errors.New("session token key not configured") - } - parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) - if err != nil { - return nil, err - } - var std jwt.Claims - custom := map[string]any{} - if err := parsed.Claims(a.sessionTokenKey, &std, &custom); err != nil { - return nil, err - } - if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil { - return nil, err - } - if typ, _ := custom["typ"].(string); typ != "session" { - return nil, errors.New("not a session token") - } - if std.Subject == "" { - return nil, errors.New("session token missing subject") - } - scope, _ := custom["scope"].(string) - if scope != "full" && scope != "guest" { - return nil, errors.New("session token invalid scope") - } - // Bind the token to its live web_session row (sid): once that row is revoked - // (deleted), the token is rejected on its very next request — "log out this - // device" takes effect immediately rather than after the access token's TTL. - sid, _ := custom["sid"].(string) - if sid == "" { - return nil, errors.New("session token missing sid") - } - if _, err := a.store.GetWebSession(ctx, sid); err != nil { - return nil, errors.New("session revoked") - } - return &Claims{UserID: std.Subject, SessionScope: scope}, nil -} - -func (a *Authenticator) verifyDev(token string, r *http.Request) (*Claims, error) { - if subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 { +// devClaims authenticates the local dev token and synthesises claims. X-Dev-User sets +// the subject (default "dev-user"); X-Dev-Scope simulates a tier ("guest" → restricted, +// else full); X-Dev-Device optionally sets a device_id to exercise device flows. +func (a *Authenticator) devClaims(r *http.Request) (*Claims, error) { + token, ok := bearerToken(r) + if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 { return nil, errors.New("invalid dev token") } userID := r.Header.Get("X-Dev-User") if userID == "" { userID = "dev-user" } - return &Claims{UserID: userID, Groups: []string{"dev"}}, nil + scope := r.Header.Get("X-Dev-Scope") + if scope == "" { + scope = "full" + } + return &Claims{ + UserID: userID, + Name: userID, + Groups: []string{"dev"}, + Scope: scope, + DeviceID: r.Header.Get("X-Dev-Device"), + }, nil } -func (a *Authenticator) verifyHS256(ctx context.Context, token string) (*Claims, error) { - if len(a.hsKey) == 0 { - return nil, errors.New("HS256 secret not configured") +// splitRoles parses a comma-separated X-Auth-Roles header into a role slice, +// trimming whitespace and dropping empties. +func splitRoles(raw string) []string { + if raw == "" { + return nil } - parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) - if err != nil { - return nil, err - } - var std jwt.Claims - custom := map[string]any{} - if err := parsed.Claims(a.hsKey, &std, &custom); err != nil { - return nil, err - } - if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil { - return nil, err - } - claims, err := claimsFromJWT(std, custom) - if err != nil { - return nil, err - } - - // HS256 is only ever used to mint scoped shortcut tokens, and those ALWAYS - // carry a jti. A validly-signed HS256 token without one must be rejected - // outright: otherwise it would fall through here as a full, unscoped account - // session with a self-declared subject — far beyond the clipboard-only - // surface HS256 is meant for. Requiring the jti keeps a leaked HS256 secret's - // blast radius pinned to the shortcut scope (clipboard, and nothing else). - if std.ID == "" { - return nil, errors.New("HS256 token missing jti") - } - - // The signature alone is not enough — the token must still be present and not - // revoked in the store, so a leaked or retired token can be killed - // server-side. The stored row is also the authoritative source of scopes - // (never trust scopes off the wire). - row, err := a.store.GetShortcutToken(ctx, std.ID) - if err != nil { - return nil, fmt.Errorf("shortcut token lookup: %w", err) - } - if row.Revoked != 0 { - return nil, errors.New("shortcut token revoked") - } - if row.UserID != claims.UserID { - return nil, errors.New("shortcut token subject mismatch") - } - claims.JTI = std.ID - claims.Scopes = strings.Fields(row.Scopes) - a.touchTokenAsync(std.ID) - return claims, nil -} - -// touchTokenAsync records a shortcut token's last-use time off the request path -// — it's audit metadata, so a slow or failing write must never delay or fail -// the request it belongs to. -func (a *Authenticator) touchTokenAsync(jti string) { - now := time.Now().Unix() - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := a.store.TouchShortcutTokenUsed(ctx, db.TouchShortcutTokenUsedParams{ - LastUsedAt: &now, - Jti: jti, - }); err != nil { - slog.Warn("touch shortcut token failed", "err", err, "jti", jti) - } - }() -} - -func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims, error) { - if a.jwks == nil { - return nil, errors.New("JWKS not configured") - } - parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256}) - if err != nil { - return nil, err - } - if len(parsed.Headers) == 0 { - return nil, errors.New("missing token headers") - } - kid := parsed.Headers[0].KeyID - key, err := a.jwks.GetKey(ctx, kid) - if err != nil { - return nil, err - } - var std jwt.Claims - custom := map[string]any{} - if err := parsed.Claims(key, &std, &custom); err != nil { - return nil, err - } - expected := jwt.Expected{Time: time.Now()} - if a.cfg.OIDCIssuer != "" { - expected.Issuer = a.cfg.OIDCIssuer - } - if a.cfg.OIDCAudience != "" { - expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience) - } - if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil { - return nil, err - } - return claimsFromJWT(std, custom) -} - -// VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience) -// from a fresh prompt=login exchange and returns its subject and auth_time (the -// epoch second of the actual end-user authentication, or 0 when the IdP omits the -// claim — Casdoor does). Used for step-up re-auth (AUTH.md §6): the caller checks -// sub matches and, only when auth_time is present, that it is recent enough. -func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) { - if a.jwks == nil { - return "", 0, errors.New("JWKS not configured") - } - parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256}) - if err != nil { - return "", 0, err - } - if len(parsed.Headers) == 0 { - return "", 0, errors.New("missing token headers") - } - key, err := a.jwks.GetKey(ctx, parsed.Headers[0].KeyID) - if err != nil { - return "", 0, err - } - var std jwt.Claims - custom := map[string]any{} - if err := parsed.Claims(key, &std, &custom); err != nil { - return "", 0, err - } - expected := jwt.Expected{Time: time.Now()} - if a.cfg.OIDCIssuer != "" { - expected.Issuer = a.cfg.OIDCIssuer - } - if a.cfg.OIDCAudience != "" { - expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience) - } - if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil { - return "", 0, err - } - if std.Subject == "" { - return "", 0, errors.New("missing subject claim") - } - // auth_time is OPTIONAL: required by OIDC only when the IdP chooses to honour - // max_age, and Casdoor omits it entirely. Absent → 0; the caller treats the - // fresh single-use prompt=login code as the freshness bound instead. - at, _ := numericClaim(custom["auth_time"]) - return std.Subject, at, nil -} - -// numericClaim coerces a JSON number claim (float64 from stdlib unmarshal, or -// json.Number / int64) to int64. -func numericClaim(v any) (int64, bool) { - switch n := v.(type) { - case float64: - return int64(n), true - case int64: - return n, true - case json.Number: - if i, err := n.Int64(); err == nil { - return i, true - } - } - return 0, false -} - -// parseAudiences splits a comma-separated OIDCAudience config into a jwt.Audience -// set. Multiple values let one backend accept tokens minted for several OAuth -// clients (the web app and the desktop client carry different `aud`); go-jose's -// AnyAudience passes when the token's audience matches any one entry. Whitespace -// around entries is trimmed and empties dropped, so a plain single value behaves -// exactly as before. -func parseAudiences(raw string) jwt.Audience { parts := strings.Split(raw, ",") - out := make(jwt.Audience, 0, len(parts)) + out := make([]string, 0, len(parts)) for _, p := range parts { if s := strings.TrimSpace(p); s != "" { out = append(out, s) @@ -358,21 +135,6 @@ func parseAudiences(raw string) jwt.Audience { return out } -func claimsFromJWT(std jwt.Claims, custom map[string]any) (*Claims, error) { - if std.Subject == "" { - return nil, errors.New("missing subject claim") - } - c := &Claims{UserID: std.Subject} - if g, ok := custom["groups"].([]any); ok { - for _, item := range g { - if s, ok := item.(string); ok { - c.Groups = append(c.Groups, s) - } - } - } - return c, nil -} - func bearerToken(r *http.Request) (string, bool) { h := r.Header.Get("Authorization") const prefix = "Bearer " @@ -396,36 +158,12 @@ func unauthorized(w http.ResponseWriter, reason string) { }) } -// DeriveHS256Key turns a configured secret of any length into a fixed 32-byte -// HMAC key. HS256 requires >= 32 bytes; hashing guarantees that (and keeps the -// minting and verifying sides in lockstep) so a short CDROP_HS256_SECRET can't -// make shortcut-token signing fail. Both signing (httpapi) and verifying use it. -func DeriveHS256Key(secret string) []byte { - sum := sha256.Sum256([]byte(secret)) - return sum[:] -} - -// DeriveSessionTokenKey derives the HMAC key for cdrop's self-signed session -// access tokens from CDROP_SESSION_SECRET. Domain-separated from both the at-rest -// refresh-token AES key (plain sha256(secret), in httpapi) and the shortcut-token -// key (DeriveHS256Key) so one secret yields three independent keys — a session -// token can never validate as a shortcut token, or vice versa. Empty secret → -// nil, which disables both minting and verifying (dev / unconfigured prod). -func DeriveSessionTokenKey(secret string) []byte { - if secret == "" { - return nil - } - sum := sha256.Sum256([]byte("cdrop-session-jwt\x00" + secret)) - return sum[:] -} - -// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device -// names ride in the X-Device-Name HTTP header, which can't carry non-ASCII -// reliably (and browser fetch rejects such header values outright), so the name -// is restricted to printable ASCII everywhere. Here we keep only printable ASCII -// (0x20–0x7E), trim, and cap the length as a server-side backstop; clients also -// validate the name up front for a clear error. Empty after sanitising → the -// caller falls back to a UA-derived default. +// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device names +// ride in the X-Device-Name HTTP header, which can't carry non-ASCII reliably (and +// browser fetch rejects such header values outright), so the name is restricted to +// printable ASCII everywhere. Here we keep only printable ASCII (0x20–0x7E), trim, and +// cap the length as a server-side backstop; clients also validate up front. Empty after +// sanitising → the caller falls back to a default. func SanitizeDeviceName(raw string) string { var b strings.Builder for _, r := range raw { @@ -440,10 +178,9 @@ func SanitizeDeviceName(raw string) string { return name } -// normalizeDeviceType whitelists the client-declared X-Device-Type so a device -// row only ever carries a known kind; anything unrecognised (incl. empty) falls -// back to "browser", the default web client. Desktop clients send macos/windows; -// "ios" is reserved for a future native iOS client (the Shortcut never sends it). +// normalizeDeviceType whitelists the client-declared X-Device-Type so a device row +// only ever carries a known kind; anything unrecognised (incl. empty) falls back to +// "browser". Native clients send macos/windows/linux/ios. func normalizeDeviceType(raw string) string { switch t := strings.ToLower(strings.TrimSpace(raw)); t { case "macos", "windows", "linux", "ios", "browser": diff --git a/internal/jwtauth/middleware_test.go b/internal/jwtauth/middleware_test.go index 2d7aa67..edb9c3b 100644 --- a/internal/jwtauth/middleware_test.go +++ b/internal/jwtauth/middleware_test.go @@ -2,596 +2,171 @@ package jwtauth import ( "context" - "crypto/rand" - "crypto/rsa" - "database/sql" - "encoding/json" "net/http" "net/http/httptest" "testing" - "time" - - "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" "commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/db" ) -// fakeDeviceUpserter records UpsertDevice calls without touching SQL and serves -// shortcut-token lookups from an in-memory map (empty → "not found", so plain -// device-only tests are unaffected). -type fakeDeviceUpserter struct { - calls []db.UpsertDeviceParams - err error - tokens map[string]db.ShortcutToken - revokedSIDs map[string]bool +type fakeDeviceStore struct { + touched []db.TouchDeviceParams } -func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error { - f.calls = append(f.calls, arg) - return f.err -} - -func (f *fakeDeviceUpserter) GetShortcutToken(_ context.Context, jti string) (db.ShortcutToken, error) { - if t, ok := f.tokens[jti]; ok { - return t, nil - } - return db.ShortcutToken{}, sql.ErrNoRows -} - -// TouchShortcutTokenUsed is a no-op in tests: it runs on a detached goroutine -// (touchTokenAsync), so recording into the fake here would race the test body. -func (f *fakeDeviceUpserter) TouchShortcutTokenUsed(_ context.Context, _ db.TouchShortcutTokenUsedParams) error { +func (f *fakeDeviceStore) TouchDevice(_ context.Context, arg db.TouchDeviceParams) error { + f.touched = append(f.touched, arg) return nil } -// GetWebSession backs the self-token revocation check. revokedSIDs lets a test -// simulate a revoked session (deleted row); any other sid resolves to a live row. -func (f *fakeDeviceUpserter) GetWebSession(_ context.Context, id string) (db.WebSession, error) { - if f.revokedSIDs[id] { - return db.WebSession{}, sql.ErrNoRows - } - return db.WebSession{ID: id, UserID: "user-x"}, nil -} - -// echo handler that responds with the claims+device discovered in context. -func echoMe(w http.ResponseWriter, r *http.Request) { - claims, _ := ClaimsFromContext(r.Context()) - dev, _ := DeviceNameFromContext(r.Context()) - resp := map[string]any{ - "user_id": claims.UserID, - "groups": claims.Groups, - "device": dev, - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) -} - -func TestDevMode_AcceptsTokenAndUsesXDevUser(t *testing.T) { - cfg := &config.Config{AuthMode: "dev", DevToken: "secret-token"} - dev := &fakeDeviceUpserter{} - a := New(cfg, dev) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - req.Header.Set("Authorization", "Bearer secret-token") - req.Header.Set("X-Dev-User", "alice") - req.Header.Set("X-Device-Name", "tab-1") - - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) - } - var got map[string]any - if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got["user_id"] != "alice" { - t.Errorf("user_id: got %v, want alice", got["user_id"]) - } - if got["device"] != "tab-1" { - t.Errorf("device: got %v, want tab-1", got["device"]) - } - if len(dev.calls) != 1 { - t.Errorf("UpsertDevice calls: got %d, want 1", len(dev.calls)) - } -} - -func TestDevMode_DefaultsUserIDWhenHeaderMissing(t *testing.T) { - cfg := &config.Config{AuthMode: "dev", DevToken: "t"} - a := New(cfg, &fakeDeviceUpserter{}) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - req.Header.Set("Authorization", "Bearer t") - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("status: got %d, want 200", rr.Code) - } - var got map[string]any - _ = json.Unmarshal(rr.Body.Bytes(), &got) - if got["user_id"] != "dev-user" { - t.Errorf("user_id: got %v, want dev-user", got["user_id"]) - } -} - -func TestDevMode_RejectsWrongToken(t *testing.T) { - cfg := &config.Config{AuthMode: "dev", DevToken: "right"} - a := New(cfg, &fakeDeviceUpserter{}) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - req.Header.Set("Authorization", "Bearer wrong") - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Fatalf("status: got %d, want 401", rr.Code) - } -} - -func TestDevMode_RejectsMissingHeader(t *testing.T) { - cfg := &config.Config{AuthMode: "dev", DevToken: "x"} - a := New(cfg, &fakeDeviceUpserter{}) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Fatalf("status: got %d, want 401", rr.Code) - } -} - -// --- prod RS256 path --- - -func newRSAKey(t *testing.T) *rsa.PrivateKey { - t.Helper() - k, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("rsa keygen: %v", err) - } - return k -} - -func startJWKSServer(t *testing.T, kid string, pub *rsa.PublicKey) *httptest.Server { - t.Helper() - jwk := jose.JSONWebKey{Key: pub, KeyID: kid, Algorithm: "RS256", Use: "sig"} - set := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}} - body, err := json.Marshal(set) - if err != nil { - t.Fatalf("marshal jwks: %v", err) - } - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) +// runMiddleware drives a request through the auth middleware and captures the claims +// the downstream handler sees (nil if the request was rejected before reaching it). +func runMiddleware(a *Authenticator, r *http.Request) (*httptest.ResponseRecorder, *Claims) { + var captured *Claims + h := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, rr *http.Request) { + if c, ok := ClaimsFromContext(rr.Context()); ok { + captured = c + } + w.WriteHeader(http.StatusOK) })) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w, captured } -func signRS256(t *testing.T, priv *rsa.PrivateKey, kid string, claims jwt.Claims, custom map[string]any) string { - t.Helper() - signer, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.RS256, Key: priv}, - (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid), - ) - if err != nil { - t.Fatalf("new signer: %v", err) - } - tok, err := jwt.Signed(signer).Claims(claims).Claims(custom).Serialize() - if err != nil { - t.Fatalf("sign: %v", err) - } - return tok -} +func TestMiddleware_ProdReadsAuthHeaders(t *testing.T) { + a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{}) + r := httptest.NewRequest(http.MethodGet, "/api/me", nil) + r.Header.Set("X-Auth-Subject", "user-1") + r.Header.Set("X-Auth-Scope", "app:cdrop:guest") + r.Header.Set("X-Auth-Meta", "dev_abc") + r.Header.Set("X-Auth-Name", "Alice") + r.Header.Set("X-Auth-Roles", "admin, user") -func TestProdMode_AcceptsValidRS256(t *testing.T) { - priv := newRSAKey(t) - kid := "key-1" - srv := startJWKSServer(t, kid, &priv.PublicKey) - defer srv.Close() - - cfg := &config.Config{ - AuthMode: "prod", - OIDCJWKSURL: srv.URL, - OIDCIssuer: "https://oauth.example/", - OIDCAudience: "cdrop", + w, c := runMiddleware(a, r) + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200", w.Code) } - a := New(cfg, &fakeDeviceUpserter{}) - - now := time.Now() - std := jwt.Claims{ - Issuer: "https://oauth.example/", - Subject: "user-bob", - Audience: jwt.Audience{"cdrop"}, - IssuedAt: jwt.NewNumericDate(now), - Expiry: jwt.NewNumericDate(now.Add(time.Hour)), + if c == nil { + t.Fatal("claims missing from context") } - tok := signRS256(t, priv, kid, std, map[string]any{"groups": []any{"users"}}) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - req.Header.Set("Authorization", "Bearer "+tok) - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) + if c.UserID != "user-1" || c.DeviceID != "dev_abc" || c.Name != "Alice" { + t.Errorf("claims wrong: %+v", c) } - var got map[string]any - _ = json.Unmarshal(rr.Body.Bytes(), &got) - if got["user_id"] != "user-bob" { - t.Errorf("user_id: got %v, want user-bob", got["user_id"]) + if !c.Guest() { + t.Error("app:cdrop:guest scope should mark Guest()") + } + if len(c.Groups) != 2 || c.Groups[0] != "admin" || c.Groups[1] != "user" { + t.Errorf("groups: got %v", c.Groups) } } -func TestProdMode_RejectsExpiredRS256(t *testing.T) { - priv := newRSAKey(t) - kid := "key-1" - srv := startJWKSServer(t, kid, &priv.PublicKey) - defer srv.Close() - - cfg := &config.Config{ - AuthMode: "prod", - OIDCJWKSURL: srv.URL, - OIDCIssuer: "https://oauth.example/", - OIDCAudience: "cdrop", +func TestMiddleware_ProdMissingSubjectRejected(t *testing.T) { + a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{}) + r := httptest.NewRequest(http.MethodGet, "/api/me", nil) + w, c := runMiddleware(a, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("no X-Auth-Subject: got %d, want 401", w.Code) } - a := New(cfg, &fakeDeviceUpserter{}) - - past := time.Now().Add(-time.Hour) - std := jwt.Claims{ - Issuer: "https://oauth.example/", - Subject: "user-bob", - Audience: jwt.Audience{"cdrop"}, - IssuedAt: jwt.NewNumericDate(past), - Expiry: jwt.NewNumericDate(past.Add(time.Minute)), // already 59min stale - } - tok := signRS256(t, priv, kid, std, nil) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - req.Header.Set("Authorization", "Bearer "+tok) - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String()) + if c != nil { + t.Error("handler must not run on rejected request") } } -func TestProdMode_RejectsWrongIssuer(t *testing.T) { - priv := newRSAKey(t) - kid := "key-1" - srv := startJWKSServer(t, kid, &priv.PublicKey) - defer srv.Close() - - cfg := &config.Config{ - AuthMode: "prod", - OIDCJWKSURL: srv.URL, - OIDCIssuer: "https://oauth.example/", - OIDCAudience: "cdrop", +func TestMiddleware_TouchesManagedDevice(t *testing.T) { + fs := &fakeDeviceStore{} + a := New(&config.Config{AuthMode: "prod"}, fs) + r := httptest.NewRequest(http.MethodGet, "/api/me", nil) + r.Header.Set("X-Auth-Subject", "user-1") + r.Header.Set("X-Auth-Scope", "app:cdrop:full") + r.Header.Set("X-Auth-Meta", "dev_x") + runMiddleware(a, r) + if len(fs.touched) != 1 { + t.Fatalf("touch count: got %d, want 1", len(fs.touched)) } - a := New(cfg, &fakeDeviceUpserter{}) - - now := time.Now() - std := jwt.Claims{ - Issuer: "https://attacker.example/", - Subject: "user-bob", - Audience: jwt.Audience{"cdrop"}, - IssuedAt: jwt.NewNumericDate(now), - Expiry: jwt.NewNumericDate(now.Add(time.Hour)), - } - tok := signRS256(t, priv, kid, std, nil) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - req.Header.Set("Authorization", "Bearer "+tok) - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String()) + if fs.touched[0].DeviceID != "dev_x" || fs.touched[0].Tier != "full" || fs.touched[0].UserID != "user-1" { + t.Errorf("touch params: %+v", fs.touched[0]) } } -// Multi-audience (R1): one backend serving web + desktop clients. A desktop -// token carries aud=; it must pass when OIDCAudience lists -// both client_ids comma-separated, and still be rejected when its aud is in -// neither. -func TestProdMode_AcceptsSecondAudienceInList(t *testing.T) { - priv := newRSAKey(t) - kid := "key-1" - srv := startJWKSServer(t, kid, &priv.PublicKey) - defer srv.Close() - - cfg := &config.Config{ - AuthMode: "prod", - OIDCJWKSURL: srv.URL, - OIDCIssuer: "https://oauth.example/", - OIDCAudience: "cdrop-web , cdrop-desktop", // spaces trimmed - } - a := New(cfg, &fakeDeviceUpserter{}) - - now := time.Now() - std := jwt.Claims{ - Issuer: "https://oauth.example/", - Subject: "user-bob", - Audience: jwt.Audience{"cdrop-desktop"}, // the desktop client's aud - IssuedAt: jwt.NewNumericDate(now), - Expiry: jwt.NewNumericDate(now.Add(time.Hour)), - } - tok := signRS256(t, priv, kid, std, nil) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - req.Header.Set("Authorization", "Bearer "+tok) - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) +func TestMiddleware_SkipsTouchWhenUnmanaged(t *testing.T) { + fs := &fakeDeviceStore{} + a := New(&config.Config{AuthMode: "prod"}, fs) + r := httptest.NewRequest(http.MethodGet, "/api/me", nil) + r.Header.Set("X-Auth-Subject", "user-1") + r.Header.Set("X-Auth-Scope", "full") + // no X-Auth-Meta → unmanaged caller (e.g. a global SSO browser) + runMiddleware(a, r) + if len(fs.touched) != 0 { + t.Errorf("unmanaged caller should not touch a device row; got %d", len(fs.touched)) } } -func TestProdMode_RejectsAudienceNotInList(t *testing.T) { - priv := newRSAKey(t) - kid := "key-1" - srv := startJWKSServer(t, kid, &priv.PublicKey) - defer srv.Close() - - cfg := &config.Config{ - AuthMode: "prod", - OIDCJWKSURL: srv.URL, - OIDCIssuer: "https://oauth.example/", - OIDCAudience: "cdrop-web,cdrop-desktop", +func TestMiddleware_DevMode(t *testing.T) { + a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{}) + r := httptest.NewRequest(http.MethodGet, "/api/me", nil) + r.Header.Set("Authorization", "Bearer devtok") + r.Header.Set("X-Dev-User", "dev-alice") + r.Header.Set("X-Dev-Scope", "guest") + w, c := runMiddleware(a, r) + if w.Code != http.StatusOK || c == nil { + t.Fatalf("dev auth failed: %d", w.Code) } - a := New(cfg, &fakeDeviceUpserter{}) - - now := time.Now() - std := jwt.Claims{ - Issuer: "https://oauth.example/", - Subject: "user-bob", - Audience: jwt.Audience{"some-other-client"}, - IssuedAt: jwt.NewNumericDate(now), - Expiry: jwt.NewNumericDate(now.Add(time.Hour)), - } - tok := signRS256(t, priv, kid, std, nil) - - req := httptest.NewRequest(http.MethodGet, "/api/me", nil) - req.Header.Set("Authorization", "Bearer "+tok) - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String()) + if c.UserID != "dev-alice" || !c.Guest() { + t.Errorf("dev claims wrong: %+v", c) } } -// A nameless request (no X-Device-Name) must NOT register a device — preventing -// the random-fallback "Unknown Device" flood from polling clients. -func TestNamelessRequestSkipsDeviceUpsert(t *testing.T) { - cfg := &config.Config{AuthMode: "dev", DevToken: "t"} - store := &fakeDeviceUpserter{} - a := New(cfg, store) - - req := httptest.NewRequest(http.MethodGet, "/api/clipboard/version", nil) - req.Header.Set("Authorization", "Bearer t") // no X-Device-Name - rr := httptest.NewRecorder() - a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })).ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("status: got %d, want 200", rr.Code) - } - if len(store.calls) != 0 { - t.Errorf("nameless request must not upsert a device, got %d calls", len(store.calls)) +func TestMiddleware_DevModeBadToken(t *testing.T) { + a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{}) + r := httptest.NewRequest(http.MethodGet, "/api/me", nil) + r.Header.Set("Authorization", "Bearer wrong") + w, _ := runMiddleware(a, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("bad dev token: got %d, want 401", w.Code) } } -func signHS256(t *testing.T, secret, sub, jti, scope string, exp time.Time) string { - t.Helper() - sig, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.HS256, Key: DeriveHS256Key(secret)}, - (&jose.SignerOptions{}).WithType("JWT"), - ) - if err != nil { - t.Fatalf("signer: %v", err) +func TestClaimsTier(t *testing.T) { + cases := []struct { + scope string + tier string + guest bool + }{ + {"app:cdrop:guest", "guest", true}, + {"app:cdrop:full", "full", false}, + {"full", "full", false}, + {"app:cdrop", "cdrop", false}, + {"", "", false}, } - std := jwt.Claims{ - Subject: sub, - ID: jti, - IssuedAt: jwt.NewNumericDate(time.Now()), - Expiry: jwt.NewNumericDate(exp), - } - tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize() - if err != nil { - t.Fatalf("serialize: %v", err) - } - return tok -} - -func serveBearer(a *Authenticator, tok string) (int, *Claims) { - req := httptest.NewRequest(http.MethodGet, "/api/clipboard", nil) - req.Header.Set("Authorization", "Bearer "+tok) - req.Header.Set("X-Device-Name", "iPhone") - rr := httptest.NewRecorder() - var got *Claims - a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got, _ = ClaimsFromContext(r.Context()) - w.WriteHeader(http.StatusOK) - })).ServeHTTP(rr, req) - return rr.Code, got -} - -// A valid HS256 shortcut token authenticates and carries its jti + stored scope; -// revocation, an unknown jti, and a subject/owner mismatch are all rejected even -// though the signature is valid — the store is the authority. -func TestShortcutToken_HS256VerifyRevokeAndScope(t *testing.T) { - secret := "test-hs256-secret-at-least-32-bytes-long" // HS256 needs >= 32 bytes - cfg := &config.Config{AuthMode: "prod", HS256Secret: secret} - exp := time.Now().Add(time.Hour) - store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{ - "jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()}, - }} - a := New(cfg, store) - - tok := signHS256(t, secret, "user-x", "jti-1", "clipboard", exp) - - code, claims := serveBearer(a, tok) - if code != http.StatusOK { - t.Fatalf("valid token: got %d, want 200", code) - } - if claims == nil || !claims.Scoped() || claims.JTI != "jti-1" || !claims.HasScope("clipboard") { - t.Fatalf("claims not populated as scoped clipboard token: %+v", claims) - } - // The device registers under the (ASCII) X-Device-Name the request carried. - if len(store.calls) == 0 || store.calls[len(store.calls)-1].Name != "iPhone" { - t.Errorf("expected device name from header, got %+v", store.calls) - } - - // Subject mismatch: stored row owned by a different user than the token's sub. - store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "someone-else", Scopes: "clipboard", ExpiresAt: exp.Unix()} - if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized { - t.Errorf("subject mismatch: got %d, want 401", code) - } - - // Revoked row → reject. - store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", Revoked: 1, ExpiresAt: exp.Unix()} - if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized { - t.Errorf("revoked token: got %d, want 401", code) - } - - // Unknown jti (signed but no stored row) → reject. - ghost := signHS256(t, secret, "user-x", "ghost", "clipboard", exp) - if code, _ := serveBearer(a, ghost); code != http.StatusUnauthorized { - t.Errorf("unknown jti: got %d, want 401", code) - } -} - -// An HS256 token WITHOUT a jti must be rejected outright (G1): the HS256 path -// only ever signs scoped shortcut tokens, which always carry a jti. A jti-less -// but validly-signed token would otherwise fall through as a full, unscoped -// account session with a self-declared subject — so a leaked HS256 secret could -// mint arbitrary-subject sessions far beyond the clipboard scope. Requiring the -// jti pins a leaked secret's blast radius to clipboard-only. -func TestShortcutToken_HS256RejectsMissingJTI(t *testing.T) { - secret := "test-hs256-secret-at-least-32-bytes-long" - cfg := &config.Config{AuthMode: "prod", HS256Secret: secret} - a := New(cfg, &fakeDeviceUpserter{}) - - tok := signHS256(t, secret, "user-x", "", "clipboard", time.Now().Add(time.Hour)) - if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized { - t.Errorf("jti-less HS256 token must be rejected, got %d", code) - } -} - -func TestSanitizeDeviceName(t *testing.T) { - cases := []struct{ in, want string }{ - {"iPhone", "iPhone"}, - {" My iPad ", "My iPad"}, - {"我的iPhone", "iPhone"}, // CJK stripped - {"我的电脑", ""}, // all non-ASCII → empty (caller falls back) - {"Mac Book", "MacBook"}, // NBSP dropped - } - for _, c := range cases { - if got := SanitizeDeviceName(c.in); got != c.want { - t.Errorf("SanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want) + for _, tc := range cases { + c := &Claims{Scope: tc.scope} + if c.Tier() != tc.tier { + t.Errorf("Tier(%q): got %q, want %q", tc.scope, c.Tier(), tc.tier) + } + if c.Guest() != tc.guest { + t.Errorf("Guest(%q): got %v, want %v", tc.scope, c.Guest(), tc.guest) } } } -// A self-signed session token is bound to its web_session row: once the row is -// revoked (deleted), the token is rejected on its next request even though it is -// still cryptographically valid and unexpired — "log out this device" is immediate -// (AUTH.md §1/§3.1). -func TestSelfToken_RejectedAfterSessionRevoked(t *testing.T) { - secret := "test-session-secret-at-least-32-bytes-long" - exp := time.Now().Add(time.Hour) - - live := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{}) - if c, _ := serveBearer(live, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusOK { - t.Fatalf("token with a live session: got %d, want 200", c) +func TestSanitizeDeviceName(t *testing.T) { + if got := SanitizeDeviceName(" Alice's Mac "); got != "Alice's Mac" { + t.Errorf("trim: got %q", got) } - - revoked := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, - &fakeDeviceUpserter{revokedSIDs: map[string]bool{"test-sid": true}}) - if c, _ := serveBearer(revoked, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized { - t.Errorf("token whose session was revoked must be rejected, got %d", c) + // Non-ASCII is stripped (the name rides an HTTP header). + if got := SanitizeDeviceName("名字abc"); got != "abc" { + t.Errorf("non-ascii strip: got %q", got) } } -// signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken -// does: HS256 over DeriveSessionTokenKey, with typ + scope custom claims and no jti. -func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time) string { - t.Helper() - sig, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.HS256, Key: DeriveSessionTokenKey(secret)}, - (&jose.SignerOptions{}).WithType("JWT"), - ) - if err != nil { - t.Fatalf("signer: %v", err) +func TestNormalizeDeviceType(t *testing.T) { + for _, in := range []string{"macos", "windows", "linux", "ios", "browser"} { + if got := normalizeDeviceType(in); got != in { + t.Errorf("normalizeDeviceType(%q): got %q", in, got) + } } - std := jwt.Claims{ - Subject: sub, - IssuedAt: jwt.NewNumericDate(time.Now()), - Expiry: jwt.NewNumericDate(exp), - } - tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope, "sid": "test-sid"}).Serialize() - if err != nil { - t.Fatalf("serialize: %v", err) - } - return tok -} - -// A cdrop self-signed session token authenticates as a full or guest session; -// guest is capability-limited (Guest() true). A wrong typ or an unknown scope is -// rejected, and the SessionSecret-derived key is domain-isolated from the HS256 -// shortcut key so the two token families never cross-validate (AUTH.md §3.1). -func TestSelfToken_ScopeAndKeyIsolation(t *testing.T) { - secret := "test-session-secret-at-least-32-bytes-long" - a := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{}) - exp := time.Now().Add(time.Hour) - - // Full session: authenticates, not scoped, not guest. - code, claims := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "full", exp)) - if code != http.StatusOK { - t.Fatalf("full session token: got %d, want 200", code) - } - if claims == nil || claims.UserID != "user-x" || claims.SessionScope != "full" || claims.Scoped() || claims.Guest() { - t.Fatalf("full session claims wrong: %+v", claims) - } - - // Guest session: authenticates and is marked guest (capability-limited). - code, claims = serveBearer(a, signSelfToken(t, secret, "user-x", "session", "guest", exp)) - if code != http.StatusOK || claims == nil || !claims.Guest() || claims.Scoped() { - t.Fatalf("guest session: code=%d claims=%+v", code, claims) - } - - // Wrong typ (not "session") signed with the session key → rejected. - if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "other", "full", exp)); c != http.StatusUnauthorized { - t.Errorf("wrong typ: got %d, want 401", c) - } - // Unknown scope → rejected (no privilege-by-typo). - if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "admin", exp)); c != http.StatusUnauthorized { - t.Errorf("invalid scope: got %d, want 401", c) - } - - // Key-domain isolation: with BOTH secrets set, the two families stay disjoint — - // a session token never becomes scoped, a shortcut token never becomes a session. - hsSecret := "test-hs256-secret-at-least-32-bytes-long" - store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{ - "jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()}, - }} - ab := New(&config.Config{AuthMode: "prod", SessionSecret: secret, HS256Secret: hsSecret}, store) - if _, sc := serveBearer(ab, signSelfToken(t, secret, "user-x", "session", "guest", exp)); sc == nil || sc.Scoped() || !sc.Guest() { - t.Errorf("session token leaked into scoped path: %+v", sc) - } - if _, hc := serveBearer(ab, signHS256(t, hsSecret, "user-x", "jti-1", "clipboard", exp)); hc == nil || !hc.Scoped() || hc.SessionScope != "" { - t.Errorf("shortcut token leaked into session path: %+v", hc) - } - - // No SessionSecret on the server → self tokens are unverifiable (key nil). - noSecret := New(&config.Config{AuthMode: "prod", HS256Secret: hsSecret}, &fakeDeviceUpserter{}) - if c, _ := serveBearer(noSecret, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized { - t.Errorf("self token without server SessionSecret must be rejected, got %d", c) + if got := normalizeDeviceType("rogue"); got != "browser" { + t.Errorf("unknown type should fall back to browser; got %q", got) } } diff --git a/ios/CDrop/.gitignore b/ios/CDrop/.gitignore new file mode 100644 index 0000000..fa76db5 --- /dev/null +++ b/ios/CDrop/.gitignore @@ -0,0 +1,8 @@ +# CDrop.xcodeproj 由 xcodegen 从 project.yml 生成——不提交,用 `xcodegen generate` 重建。 +CDrop.xcodeproj/ + +# Xcode 构建产物 +build/ +DerivedData/ +*.xcuserstate +.DS_Store diff --git a/ios/CDrop/REALDEVICE.md b/ios/CDrop/REALDEVICE.md new file mode 100644 index 0000000..f82b0f8 --- /dev/null +++ b/ios/CDrop/REALDEVICE.md @@ -0,0 +1,40 @@ +# cdrop iOS 真机自测手册 + +账号到位即可照做。模拟器构建不需签名,已全程 compile + 跑通;**真机**要付费 ADP + 签名 + 实体 iPhone,下面是 turnkey 步骤 + 只能真机验的清单。 + +## 前置 +- 付费 Apple Developer Program($99/年)。 +- 一台 iPhone(iOS 26),Mac 装 Xcode 26。 +- 已登录的 cdrop(网页 / 桌面)一个——用来扫码批准本机登录。 + +## 出工程 + 签名 +1. `cd ios/CDrop && xcodegen generate`(从 `project.yml` 生成 `CDrop.xcodeproj`)。 +2. Xcode 打开 `CDrop.xcodeproj` → 选 `CDrop` target → Signing & Capabilities: + - **Team** 选你的 ADP 团队;**Bundle Identifier** 用你账号下可注册的(默认 `net.commilitia.cdrop`,前缀按你的改)。 + - 让 Xcode 自动管理签名(Automatically manage signing)。 +3. 连 iPhone、信任、选作运行目标 → Run。 + - `project.yml` 里 `CODE_SIGNING_ALLOWED: NO` 是给模拟器的;Xcode 真机自动签名会覆盖它,无需手改。 + +## 引擎可达性(让传输真能连) +- 真机上引擎 WebView 默认加载 `https://drop.commilitia.net/engine.html`——**该文件要先随 web 部署到 prod**(`vite build` 已产出 `dist/engine.html`,随正常 web 发布上线)。 +- 或本机联调:设环境变量 `CDROP_ENGINE_URL` 指向可达引擎、`CDROP_API_BASE` 指向后端。 + +## 后续功能要补的 capability(按需,各自要在 Developer Portal 配 App ID) +- **App Groups**(`group.net.commilitia.cdrop`):Share Extension / 控制中心控件与主 app 共享数据。 +- **Push Notifications** + APNs `.p8`:传输事件推送。 +- **Keychain Sharing**:跨 app / 扩展共享登录态。 +- **Associated Domains**:若改用 OIDC Universal Link 回调(扫码登录则不需要)。 + +## 只能真机验的清单 +- [ ] 液态玻璃在真机渲染(含陀螺仪高光 / 动效)。 +- [ ] 扫码登录:本机显码 → 用已登录 cdrop 扫码批准 → app 进入主界面。 +- [ ] **本地网络权限**:首次同内网传输弹 `NSLocalNetworkUsageDescription` 提示;授予后 ICE 收到 host 候选(R-iOS-6);拒绝则回退中继仍可传。 +- [ ] 发送:选文件 → 选设备 → 对端收到。 +- [ ] 接收:对端发来 → 落到 Files(沙盒 Documents)。 +- [ ] 后台:传输中切后台 → `BGContinuedProcessingTask` 续传行为(R-iOS-1 / R-iOS-3)。 +- [ ] 大文件:发送侧内存(当前整文件入内存,大文件需转分块流式 = R-iOS-4)。 + +## 已知 TODO(真机阶段一并收) +- 会话 Keychain 持久化 + 自动续期(当前会话在内存、重启需重扫;续期端点 `/api/auth/refresh` 已可用,靠 cookie)。 +- 发送侧大文件分块流式(R-iOS-4)。 +- 剪贴板 App Intent + 控制中心控件(决策 C)、Share Extension——需上面的 capability + 账号才能跑。 diff --git a/ios/CDrop/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/CDrop/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..27a4f38 --- /dev/null +++ b/ios/CDrop/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/CDrop/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/CDrop/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..13f0724 Binary files /dev/null and b/ios/CDrop/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios/CDrop/Sources/Assets.xcassets/Contents.json b/ios/CDrop/Sources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/CDrop/Sources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/CDrop/Sources/Auth/AuthManager.swift b/ios/CDrop/Sources/Auth/AuthManager.swift new file mode 100644 index 0000000..2277d89 --- /dev/null +++ b/ios/CDrop/Sources/Auth/AuthManager.swift @@ -0,0 +1,223 @@ +import Foundation +import Observation +import UIKit + +// 扫码登录(新设备侧)+ 会话持有。iOS 显示二维码,已登录的 web / 桌面设备用“扫码登录” +// 扫码批准,本端轮询 /api/auth/qr/status 拿到自签会话。字段 / 流程见后端 internal/httpapi/ +// qr.go。登录是纯 REST(不经引擎 WebView),故 engine.html 未部署也能登录。 +// +// 续期(Auth Broker 路径 A):access_token 默认 900s;引擎(WebView)持注入的 refresh_token, +// 到期经 POST /api/auth/refresh {refresh_token} 自刷(cdrop 代理 broker,见 refresh.go),broker +// 轮换 refresh 后经桥 sessionRotated 回报本端更新 Keychain;refresh 过期 / 吊销则经 authExpired +// 回登录页重扫。本端不再依赖 cdrop_session cookie。 +@MainActor +@Observable +final class AuthManager +{ + struct User: Codable + { + let id: String + let name: String + let avatar: String? + } + + struct Session: Codable + { + let accessToken: String + // refreshToken:broker 委托会话的续期凭证,随 access 一并注入引擎自刷用;轮换后更新。 + let refreshToken: String + let user: User + let deviceName: String + // deviceId:cdrop 生成的稳定不透明设备 id(= broker meta,回显 X-Auth-Meta)。 + let deviceId: String + let scope: String + } + + var session: Session? + var qrPayload: String? + var statusText: String = t("ios.login.generating") + // 二维码已失效(denied/expired/失败)→ 登录页据此提示并高亮「刷新二维码」。 + var qrExpired = false + + // 登录代次:每次 startQRLogin 自增,旧轮询据此识别自己已被刷新取代、丢弃结果,避免 + // 「刷新时仍在轮询」两条循环并发改 statusText / qrPayload 打架。 + private var loginGeneration = 0 + + // Keychain 持久化坐标(单 app 私有,见 Keychain.swift)。 + private static let keychainService = "net.commilitia.cdrop.session" + private static let keychainAccount = "session" + + // 启动即尝试恢复持久会话:命中则 app 直接进主界面,免反复扫码(迭代 / 重装友好)。 + // 恢复的令牌可能已过期(guest 1h 上限)——此时引擎鉴权 401,用户经设置页登出重扫即可。 + init() + { + if let data = Keychain.load(service: Self.keychainService, account: Self.keychainAccount), + let restored = try? JSONDecoder().decode(Session.self, from: data) + { + // 仅恢复完整权限会话;陈旧 guest(旧版本落地的)丢弃、强制重登为 full。 + if restored.scope == "full" { session = restored } + else { Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) } + } + } + + // 后端基址:默认线上,可经 CDROP_API_BASE 覆盖(本机测试)。 + private var apiBase: String + { + ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? "https://drop.commilitia.net" + } + + private struct QRStart: Decodable + { + let request_id: String + let poll_secret: String + let qr_payload: String + let expires_at: Int64 + } + + private struct QRStatus: Decodable + { + let status: String + let access_token: String? + let refresh_token: String? + let device_id: String? + let expires_in: Int? + let user: User? + let device_name: String? + } + + // startQRLogin:发起二维码 + 轮询直到批准 / 失效。可重复调用(刷新二维码):自增代次, + // 旧轮询识别到代次变更即弃。 + func startQRLogin() async + { + loginGeneration += 1 + let gen = loginGeneration + qrExpired = false + qrPayload = nil + statusText = t("ios.login.generating") + do + { + let start = try await qrStart() + if gen != loginGeneration { return } + qrPayload = start.qr_payload + statusText = t("ios.login.waiting") + try await poll(requestID: start.request_id, pollSecret: start.poll_secret, gen: gen) + } + catch + { + if gen != loginGeneration { return } + statusText = t("ios.login.failed") + qrExpired = true + } + } + + func logout() + { + session = nil + qrPayload = nil + statusText = t("ios.login.generating") + Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) + } + + // 持久化当前会话到 Keychain(扫码批准后调用)。 + private func persist() + { + guard let session, let data = try? JSONEncoder().encode(session) else { return } + Keychain.save(data, service: Self.keychainService, account: Self.keychainAccount) + } + + // updateSession:引擎自刷致 broker 轮换后,经桥 sessionRotated 回报的新令牌对 → 更新 + // 内存会话 + Keychain,使重启后不再用已失效的旧 refresh。仅换令牌,身份 / 设备名不变。 + func updateSession(accessToken: String, refreshToken: String) + { + guard let cur = session else { return } + session = Session(accessToken: accessToken, refreshToken: refreshToken, + user: cur.user, deviceName: cur.deviceName, + deviceId: cur.deviceId, scope: cur.scope) + persist() + } + + // 仅供本机模拟器联调(CDROP_DEBUG_SESSION=1):注入占位会话跳过扫码,用于验证引擎从 + // prod 加载 + 桥往返。令牌无效、鉴权会 401,但能证 engine.html 在 WKWebView 里加载运行。 + func debugSession() + { + session = Session(accessToken: "debug", refreshToken: "", + user: User(id: "debug", name: "Simulator", avatar: nil), + deviceName: "Simulator", deviceId: "", scope: "guest") + } + + private func qrStart() async throws -> QRStart + { + var req = URLRequest(url: URL(string: "\(apiBase)/api/auth/qr/start")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("ios", forHTTPHeaderField: "X-Device-Type") + req.httpBody = try JSONSerialization.data( + withJSONObject: [ "device_name": deviceName(), "device_type": "ios" ]) + let (data, _) = try await URLSession.shared.data(for: req) + return try JSONDecoder().decode(QRStart.self, from: data) + } + + private func poll(requestID: String, pollSecret: String, gen: Int) async throws + { + while session == nil && gen == loginGeneration + { + var comp = URLComponents(string: "\(apiBase)/api/auth/qr/status")! + comp.queryItems = [ URLQueryItem(name: "request_id", value: requestID) ] + var req = URLRequest(url: comp.url!) + req.setValue(pollSecret, forHTTPHeaderField: "X-Poll-Secret") + let (data, _) = try await URLSession.shared.data(for: req) + if gen != loginGeneration { return } // 已被刷新取代,丢弃本轮结果 + let st = try JSONDecoder().decode(QRStatus.self, from: data) + switch st.status + { + case "approved": + if let token = st.access_token, let user = st.user + { + // 原生 App 仅允许完整权限(用户原则:iOS/桌面只接受 full,受限访客是 Web/PWA + // 的权宜)。批准方若按「仅此次」授予 guest,这里据 /api/me 的真实 scope 拒绝、 + // 提示改选「信任此设备」,并让二维码可刷新重扫——不落地 guest 会话。 + let scope = await fetchScope(token: token) + if gen != loginGeneration { return } + if scope != "full" + { + statusText = t("ios.login.needFull") + qrExpired = true + return + } + session = Session(accessToken: token, + refreshToken: st.refresh_token ?? "", + user: user, + deviceName: st.device_name ?? deviceName(), + deviceId: st.device_id ?? "", + scope: "full") + persist() + } + return + case "denied", "expired": + statusText = t("ios.login.expired") + qrExpired = true + return + default: + continue // pending → 立即下一轮(服务端长轮询最多挂 25s) + } + } + } + + // 据刚发的 access token 拉 /api/me 取真实会话级别(full / guest)。失败回空串(按非 full + // 处理,保守拒绝)。 + private func fetchScope(token: String) async -> String + { + var req = URLRequest(url: URL(string: "\(apiBase)/api/me")!) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + guard let (data, _) = try? await URLSession.shared.data(for: req), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let scope = obj["scope"] as? String + else { return "" } + return scope + } + + private func deviceName() -> String + { + return DeviceNameStore.value + } +} diff --git a/ios/CDrop/Sources/Auth/Keychain.swift b/ios/CDrop/Sources/Auth/Keychain.swift new file mode 100644 index 0000000..25e1084 --- /dev/null +++ b/ios/CDrop/Sources/Auth/Keychain.swift @@ -0,0 +1,53 @@ +import Foundation +import Security + +// 极简 Keychain 封装:单 app 私有 generic-password 条目,无 access group。Keychain *共享* +// (跨 app / extension)才需付费 ADP 的 entitlement;本封装不跨 app,故零 entitlement, +// 模拟器与真机均可用(见 ios/PLAN.md §9 禁区分级——共享才是账号项,私有不是)。 +// +// 用途:持久化扫码登录拿到的会话(access_token + user),使 app 重装 / 重建后免反复扫码。 +// kSecAttrAccessibleAfterFirstUnlock:设备首次解锁后即可读(含后台),符合“常驻登录”诉求, +// 又不在锁屏前暴露。 +enum Keychain +{ + static func save(_ data: Data, service: String, account: String) + { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(base as CFDictionary) // 覆盖写:先删旧条目再加,避免 duplicate item + + var attrs = base + attrs[kSecValueData as String] = data + attrs[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(attrs as CFDictionary, nil) + } + + static func load(service: String, account: String) -> Data? + { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var out: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess, + let data = out as? Data + else { return nil } + return data + } + + static func delete(service: String, account: String) + { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/CDrop/Sources/Auth/LoginView.swift b/ios/CDrop/Sources/Auth/LoginView.swift new file mode 100644 index 0000000..f6b15b6 --- /dev/null +++ b/ios/CDrop/Sources/Auth/LoginView.swift @@ -0,0 +1,86 @@ +import CoreImage.CIFilterBuiltins +import SwiftUI +import UIKit + +// 扫码登录界面:显示二维码 + 引导用已登录的 cdrop 扫码批准。轮询在 AuthManager 里。 +struct LoginView: View +{ + @Environment(AuthManager.self) private var auth + + var body: some View + { + VStack(spacing: 24) + { + Spacer() + Text(t("app.brand")) + .font(.largeTitle) + .bold() + qrArea + Text(auth.statusText) + .font(.callout) + .foregroundStyle(auth.qrExpired ? Color.red : Color.secondary) + refreshButton + Text(t("ios.login.guide")) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + Spacer() + } + .padding() + .tint(.cdropAccent) + .task { await auth.startQRLogin() } + } + + // 刷新二维码:失效后高亮(glassProminent),未失效时也常驻可手动刷新。两种玻璃样式类型 + // 不同,故经 @ViewBuilder 条件分支。 + @ViewBuilder + private var refreshButton: some View + { + let button = Button { Task { await auth.startQRLogin() } } + label: { Label(t("ios.login.refresh"), systemImage: "arrow.clockwise") } + if auth.qrExpired { button.buttonStyle(.glassProminent) } + else { button.buttonStyle(.glass) } + } + + @ViewBuilder + private var qrArea: some View + { + if let payload = auth.qrPayload, let img = qrImage(payload) + { + Image(uiImage: img) + .interpolation(.none) + .resizable() + .frame(width: 220, height: 220) + .padding(16) + .background(.white, in: RoundedRectangle(cornerRadius: 16)) + .opacity(auth.qrExpired ? 0.25 : 1) // 失效后置灰,视觉提示需刷新 + .overlay + { + if auth.qrExpired + { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(.secondary) + } + } + } + else + { + ProgressView() + .frame(width: 220, height: 220) + } + } + + // CoreImage 内建二维码生成(无第三方依赖)。payload 是后端给的完整 /link?r=..&c=.. URL。 + private func qrImage(_ string: String) -> UIImage? + { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) + let ctx = CIContext() + guard let cg = ctx.createCGImage(scaled, from: scaled.extent) else { return nil } + return UIImage(cgImage: cg) + } +} diff --git a/ios/CDrop/Sources/CDropApp.swift b/ios/CDrop/Sources/CDropApp.swift new file mode 100644 index 0000000..a17aeb8 --- /dev/null +++ b/ios/CDrop/Sources/CDropApp.swift @@ -0,0 +1,51 @@ +import SwiftUI + +// cdrop iOS 客户端入口(arch A,见 ios/PLAN.md)。原生 SwiftUI 界面(液态玻璃)+ 离屏 +// 无头 WKWebView 跑复用的传输引擎,二者经 EngineController 桥接;登录走扫码(AuthManager)。 +@main +struct CDropApp: App +{ + @State private var auth = AuthManager() + @State private var engine = EngineController() + + var body: some Scene + { + WindowGroup + { + AppRoot(auth: auth, engine: engine) + } + } +} + +// 登录门:有会话进主界面(引擎以该会话启动),无会话进扫码登录。 +struct AppRoot: View +{ + let auth: AuthManager + let engine: EngineController + + var body: some View + { + Group + { + if auth.session != nil + { + RootView() + .environment(engine) + .environment(auth) + } + else + { + LoginView() + .environment(auth) + } + } + .onAppear + { + engine.auth = auth + if ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1" + { + auth.debugSession() + } + } + } +} diff --git a/ios/CDrop/Sources/DeviceNameStore.swift b/ios/CDrop/Sources/DeviceNameStore.swift new file mode 100644 index 0000000..91b269d --- /dev/null +++ b/ios/CDrop/Sources/DeviceNameStore.swift @@ -0,0 +1,25 @@ +import Foundation +import UIKit + +// 用户可编辑的设备名,持久在 UserDefaults。用于扫码登录 qr/start 的 device_name——设备名 +// 在登录时即烤进自签会话令牌,后端按 (userID, deviceName) 记 presence,无在线改名接口 +// (见 internal/httpapi/qr.go),故改名在「下次登录」生效。默认取系统设备名。 +enum DeviceNameStore +{ + private static let key = "cdrop.deviceName" + + static var value: String + { + get + { + if let v = UserDefaults.standard.string(forKey: key), !v.isEmpty { return v } + let sys = UIDevice.current.name + return sys.isEmpty ? "iPhone" : sys + } + set + { + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + UserDefaults.standard.set(trimmed.isEmpty ? nil : trimmed, forKey: key) + } + } +} diff --git a/ios/CDrop/Sources/Engine/DownloadManager.swift b/ios/CDrop/Sources/Engine/DownloadManager.swift new file mode 100644 index 0000000..b792784 --- /dev/null +++ b/ios/CDrop/Sources/Engine/DownloadManager.swift @@ -0,0 +1,121 @@ +import Foundation + +// 接收落盘管理器(对应桌面 desktop/platform/download.go):把接收到的字节写入 app 沙盒 +// Documents。小文件整文件写(saveWhole);大文件流式 begin / append / finalize;abort +// 丢弃临时文件。线程:由 EngineController 在主线程调用,文件 IO 同步执行(首版求简单可 +// 验,后续可挪后台队列)。 +final class DownloadManager +{ + private struct Active + { + let handle: FileHandle + let tmpURL: URL + } + + private var active: [String: Active] = [:] + + private func downloadDir() -> URL + { + return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + } + + // 列出已落盘的接收文件(Documents 顶层,排除流式临时 .part / 隐藏文件),最近的在前。 + // 供应用内「收到的文件」视图浏览 / 打开 / 转发,也是 Files app 可见的同一批文件。 + func receivedFiles() -> [URL] + { + let urls = (try? FileManager.default.contentsOfDirectory( + at: downloadDir(), + includingPropertiesForKeys: [ .contentModificationDateKey ], + options: [ .skipsHiddenFiles ])) ?? [] + return urls + .filter { $0.pathExtension != "part" && !$0.lastPathComponent.hasPrefix(".cdrop-") } + .sorted { a, b in modDate(a) > modDate(b) } + } + + func deleteReceivedFile(_ url: URL) + { + try? FileManager.default.removeItem(at: url) + } + + private func modDate(_ url: URL) -> Date + { + return (try? url.resourceValues(forKeys: [ .contentModificationDateKey ]))? + .contentModificationDate ?? .distantPast + } + + // 整文件写入(接收端小文件路径,对应桥 saveDownload)。返回最终绝对路径。 + func saveWhole(name: String, data: Data) throws -> String + { + let url = uniqueURL(for: sanitize(name)) + try data.write(to: url) + return url.path + } + + func begin(sessionId: String) throws + { + cleanup(sessionId) // re-begin 安全:先清旧条目再开新文件 + let tmp = downloadDir().appendingPathComponent(".cdrop-\(sanitize(sessionId)).part") + FileManager.default.createFile(atPath: tmp.path, contents: nil) + let handle = try FileHandle(forWritingTo: tmp) + active[sessionId] = Active(handle: handle, tmpURL: tmp) + } + + func append(sessionId: String, data: Data) throws + { + guard let a = active[sessionId] else { throw Err.unknownSession } + try a.handle.write(contentsOf: data) + } + + func finalize(sessionId: String, name: String) throws -> String + { + guard let a = active[sessionId] else { throw Err.unknownSession } + try a.handle.close() + active[sessionId] = nil + let dest = uniqueURL(for: sanitize(name)) + try FileManager.default.moveItem(at: a.tmpURL, to: dest) + return dest.path + } + + func abort(sessionId: String) + { + cleanup(sessionId) + } + + private func cleanup(_ sessionId: String) + { + guard let a = active[sessionId] else { return } + try? a.handle.close() + try? FileManager.default.removeItem(at: a.tmpURL) + active[sessionId] = nil + } + + // 唯一化:若已存在则插入 (1) (2)…(对应桌面 uniquePath)。 + private func uniqueURL(for name: String) -> URL + { + let dir = downloadDir() + let base = (name as NSString).deletingPathExtension + let ext = (name as NSString).pathExtension + var candidate = dir.appendingPathComponent(name) + var i = 1 + while FileManager.default.fileExists(atPath: candidate.path) + { + let next = ext.isEmpty ? "\(base) (\(i))" : "\(base) (\(i)).\(ext)" + candidate = dir.appendingPathComponent(next) + i += 1 + } + return candidate + } + + // 文件名 sanitize:只取末段、剥路径分隔符,防目录穿越(对应桌面 sanitizeFileName)。 + private func sanitize(_ name: String) -> String + { + let last = (name as NSString).lastPathComponent + let cleaned = last.replacingOccurrences(of: "/", with: "_") + return cleaned.isEmpty ? "download" : cleaned + } + + enum Err: Error + { + case unknownSession + } +} diff --git a/ios/CDrop/Sources/Engine/EngineController.swift b/ios/CDrop/Sources/Engine/EngineController.swift new file mode 100644 index 0000000..cc35a25 --- /dev/null +++ b/ios/CDrop/Sources/Engine/EngineController.swift @@ -0,0 +1,537 @@ +import Foundation +import Observation +import UIKit +import WebKit + +// 引擎过桥推来的设备 presence(对应 web store 的 DeviceInfo)。lastSeen 是 ms epoch。 +// 不用 Decodable:过桥的 JS 值经 WKWebView 桥后类型不稳(bool 可能成 NSNumber、 +// 整数 / 浮点混用),严格 JSONDecoder 一处不匹配就整组失败、列表全空。改手动容忍解析 +// (对齐 web hub.ts handlePresence 的防御式取值)。 +struct DeviceItem: Identifiable, Equatable +{ + let name: String + let type: String + let online: Bool + let lastSeen: Double + var id: String { name } +} + +// 引擎过桥推来的传输记录(对应 web engine/main.ts 的 toWire 精简视图)。ice* 为选中候选 +// 对诊断(揭示 P2P 是否实为 TURN 中继,解释慢速)。同样手动容忍解析。 +struct TransferItem: Identifiable, Equatable +{ + let sessionId: String + let direction: String + let fileName: String + let fileSize: Int + let state: String + let mode: String? + let peerName: String + let phase: String? + let bytesTransferred: Int? + let bytesPerSec: Double? + let iceConn: String? + let iceLocal: String? + let iceRemote: String? + var id: String { sessionId } +} + +// 原生 ↔ 无头 JS 引擎的桥(对侧契约见 web/src/net/ios.ts、ios/PLAN.md §2)。 +// 注册名为 "cdropEngine" 的 WKScriptMessageHandler 收 { id, method, payload }(RPC)或 +// { notify, payload }(单向通知);RPC 处理完经 evaluateJavaScript 调 +// window.__cdropEngineResolve(id, ok, value) 回交;推命令经 window.__cdropEngineEvent。 +@MainActor +@Observable +final class EngineController: NSObject +{ + // SwiftUI 观察的引擎态。 + var status: String = t("ios.engine.disconnected") + var deviceName: String = "" + + // 引擎经桥推来的真实态,替换原生 UI 原先的 demo 数据。 + var devices: [DeviceItem] = [] // 在线 / 离线设备 presence(每次 presence 事件整组替换) + var transfers: [TransferItem] = [] // 活跃传输(每次 transfers 事件整组替换) + var history: [TransferItem] = [] // 已完成传输(transferDone 逐条前插,限长 30) + + // 诊断信号(设置页展示):SSE 是否连上 / 是否在重连 + 累计收到的 presence 事件数 + 引擎 + // 最近一条 warn/error 日志。用来定位「设备空」卡在哪一环(未连接?401?静默挂起?)。 + var hubConnected = false + var hubReconnecting = false + var presenceCount = 0 + var lastEngineLog = "" + + // 剪贴板 / 设备管理操作的瞬时反馈(设置页 / 设备页展示)。 + var clipboardStatus = "" + var deviceActionStatus = "" + + // 引擎页地址:须安全源(WebRTC 在非安全 origin 可能被禁,见 PLAN R-iOS-3)。默认线上 + // https;可经环境变量 CDROP_ENGINE_URL 覆盖(本机测试指向 http://localhost——localhost + // 是潜在可信源、算安全上下文,故 WebRTC 仍可用,同 Windows 桌面 loopback 思路)。 + private var engineURL: URL + { + if let s = ProcessInfo.processInfo.environment["CDROP_ENGINE_URL"], let u = URL(string: s) + { + return u + } + return URL(string: "https://drop.commilitia.net/engine.html")! + } + + // 会话来源(登录后注入引擎 boot)。由 app 在启动时接好(AppRoot.onAppear)。 + var auth: AuthManager? + + private var webView: WKWebView? + private let downloads = DownloadManager() + + // 待发文件暂存:id → 安全作用域 URL(文件选择器选中的文件)。引擎经 cdrop-file:// + // 回取字节(下方 WKURLSchemeHandler 供给)。 + private var outgoing: [String: URL] = [:] + + override init() + { + super.init() + } + + // makeWebView:构建离屏 WebView——注入 __CDROP_BOOT__(device_type:"ios")、注册消息 + // 处理器、注册 cdrop-file 自定义 scheme(发送侧给 JS 喂文件字节)。幂等。 + func makeWebView() -> WKWebView + { + if let existing = webView { return existing } + + let ucc = WKUserContentController() + ucc.add(self, name: "cdropEngine") + + // boot 注入:把登录后的真实会话注入引擎(无会话则 null)。device_type 标 ios 供后端 + // 登记;api_base 默认空(引擎与 /api 同源,从 prod 加载时相对 URL 即可)。 + let debug = ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1" + let boot = "window.__CDROP_BOOT__ = { session: \(sessionJSON()), " + + "device_name: \(jsString(currentDeviceName())), " + + "api_base: \(jsString(apiBaseForBoot())), device_type: \"ios\", debug: \(debug) };" + ucc.addUserScript(WKUserScript(source: boot, + injectionTime: .atDocumentStart, + forMainFrameOnly: true)) + + let cfg = WKWebViewConfiguration() + cfg.userContentController = ucc + cfg.setURLSchemeHandler(self, forURLScheme: "cdrop-file") + + // 迁移到 Auth Broker 后引擎自刷走注入的 refresh_token(POST /api/auth/refresh 代理 + // broker),不再依赖 cdrop_session cookie,故无需把 URLSession 的 cookie 同步进 WebView。 + + let wv = WKWebView(frame: .zero, configuration: cfg) + // 始终重新拉取 engine.html(忽略本地缓存),否则引擎更新后 WebView 可能仍跑旧包, + // 新命令(剪贴板 / 设备管理等)在旧引擎里没有对应 handler → 静默失效。engine.html 很 + // 小、且它引用的哈希化 bundle 仍按内容寻址正常缓存,开销可忽略。 + var request = URLRequest(url: engineURL) + request.cachePolicy = .reloadIgnoringLocalCacheData + wv.load(request) + webView = wv + deviceName = currentDeviceName() + return wv + } + + // 登出复位:通知引擎断开 SSE、丢弃 WebView 并清空 UI 态。下次登录时 makeWebView 以新 + // 会话重建(boot 注入读 auth.session 的最新值)。 + func reset() + { + sendCommand("shutdown", payload: [:]) + webView = nil + devices = [] + transfers = [] + history = [] + status = t("ios.engine.disconnected") + deviceName = "" + } + + // 收到的文件(Documents 沙盒,与 Files app 同一批)。供「收到的文件」视图浏览 / 转发。 + func receivedFiles() -> [URL] { downloads.receivedFiles() } + func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) } + + // 按文件名在已落盘文件里找对应项(传输详情「打开文件」直达用)。落盘时同名会被 + // 唯一化为「name (1)」,故先精确匹配、再退回基名前缀匹配。 + func receivedFile(matching name: String) -> URL? + { + let files = downloads.receivedFiles() + if let exact = files.first(where: { $0.lastPathComponent == name }) { return exact } + let base = (name as NSString).deletingPathExtension + return files.first { $0.lastPathComponent.hasPrefix(base) } + } + + // 可发送目标:在线、且非本机(不发给自己)。发送 / 转发两处入口共用此过滤。 + func sendableDevices() -> [DeviceItem] + { + return devices.filter { $0.online && $0.name != deviceName } + } + + // 剪贴板上行:读本机 UIPasteboard 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。 + func uploadClipboard() + { + let content = UIPasteboard.general.string ?? "" + guard !content.isEmpty else { clipboardStatus = t("ios.clipboard.empty"); return } + clipboardStatus = t("ios.clipboard.uploading") + sendCommand("clipboardUpload", payload: [ "content": content ]) + } + + // 剪贴板下行:让引擎拉一次云剪贴板,回来写入 UIPasteboard(见 handleNotify "clipboard")。 + func pullClipboard() + { + clipboardStatus = t("ios.clipboard.pulling") + sendCommand("clipboardPull", payload: [:]) + } + + // 设备管理:移除 / 吊销一台设备。 + func revokeDevice(_ name: String) + { + deviceActionStatus = "" + sendCommand("revokeDevice", payload: [ "name": name ]) + } + + // 推命令给引擎(原生 → JS,window.__cdropEngineEvent)。 + func sendCommand(_ name: String, payload: [String: Any]) + { + guard let json = jsonString(payload) else { return } + webView?.evaluateJavaScript("window.__cdropEngineEvent(\(jsString(name)), \(json));") + } + + // sendFile:暂存选中文件、把 cdrop-file:// 引用 + 目标设备交给引擎发起传输。 + func sendFile(to target: String, fileURL: URL) + { + let ref = stageOutgoingFile(fileURL) + var size = 0 + if let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path), + let n = attrs[.size] as? Int { size = n } + sendCommand("sendFile", payload: [ + "target": target, + "url": ref, + "name": fileURL.lastPathComponent, + "size": size, + ]) + } + + // 暂存待发文件,返回引擎用的 cdrop-file:// 引用。文件选择器给的 URL 是安全作用域 + // 资源,需 start...Access 才能读。 + private func stageOutgoingFile(_ url: URL) -> String + { + let id = UUID().uuidString + _ = url.startAccessingSecurityScopedResource() + outgoing[id] = url + return "cdrop-file://\(id)" + } +} + +// MARK: - JS → 原生(RPC + 通知) + +extension EngineController: WKScriptMessageHandler +{ + func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage) + { + guard let body = message.body as? [String: Any] else { return } + if let id = body["id"] as? Int + { + handleRPC(id: id, + method: body["method"] as? String ?? "", + payload: body["payload"] as? [String: Any] ?? [:]) + return + } + if let notify = body["notify"] as? String + { + handleNotify(notify, payload: body["payload"]) + } + } + + private func handleRPC(id: Int, method: String, payload: [String: Any]) + { + do + { + switch method + { + case "saveDownload": + let name = payload["name"] as? String ?? "download" + let path = try downloads.saveWhole(name: name, data: decodeBase64(payload["data"])) + resolve(id: id, ok: true, value: path) + case "beginDownload": + try downloads.begin(sessionId: payload["sessionId"] as? String ?? "") + resolve(id: id, ok: true, value: nil) + case "appendDownload": + try downloads.append(sessionId: payload["sessionId"] as? String ?? "", + data: decodeBase64(payload["data"])) + resolve(id: id, ok: true, value: nil) + case "finalizeDownload": + let path = try downloads.finalize(sessionId: payload["sessionId"] as? String ?? "", + name: payload["name"] as? String ?? "download") + resolve(id: id, ok: true, value: path) + case "abortDownload": + downloads.abort(sessionId: payload["sessionId"] as? String ?? "") + resolve(id: id, ok: true, value: nil) + default: + resolve(id: id, ok: false, value: "unknown method: \(method)") + } + } + catch + { + resolve(id: id, ok: false, value: "\(error)") + } + } + + private func handleNotify(_ name: String, payload: Any?) + { + switch name + { + case "ready": + status = t("ios.engine.ready") + case "probe": + // 本机 WebRTC-in-WKWebView 探针结果(验 arch A 命门 R-iOS-3)。 + if let p = payload as? [String: Any] + { + let secure = (p["secure"] as? Bool) ?? false + let rtc = (p["rtc"] as? String) ?? "?" + let host = (p["host"] as? Int) ?? 0 + let srflx = (p["srflx"] as? Int) ?? 0 + status = "安全:\(secure) RTC:\(rtc) host:\(host) srflx:\(srflx)" + } + case "presence": + // 注意:桥来的 JS 数组是 NSArray,`as? [[String: Any]]` 这种嵌套泛型条件转换 + // 在 Foundation 桥接下常整体失败(即便数据合法)→ 列表全空。故先转 [Any] 再逐 + // 元素 as? [String: Any],这是先前「健壮解析」仍漏掉的真正失败点。 + if let p = payload as? [String: Any], let raw = p["devices"] as? [Any] + { + devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) } + } + case "transfers": + if let p = payload as? [String: Any], let raw = p["active"] as? [Any] + { + transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) } + } + case "transferDone": + // 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。 + if let p = payload as? [String: Any], let item = Self.parseTransfer(p) + { + transfers.removeAll { $0.sessionId == item.sessionId } + history.removeAll { $0.sessionId == item.sessionId } + history.insert(item, at: 0) + if history.count > 30 { history.removeLast(history.count - 30) } + } + case "sendStarted": + // 随后的 transfers 事件会带出该活跃项,这里无需额外处理。 + break + case "hubState": + if let p = payload as? [String: Any] + { + hubConnected = Self.boolOf(p["connected"]) + hubReconnecting = Self.boolOf(p["reconnecting"]) + presenceCount = Self.intOf(p["presenceCount"]) + } + case "log": + if let p = payload as? [String: Any], let msg = p["msg"] as? String + { + lastEngineLog = msg + } + case "clipboard": + // 引擎拉回的云剪贴板内容 → 写入本机 UIPasteboard。 + if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty + { + UIPasteboard.general.string = content + clipboardStatus = t("ios.clipboard.pulled") + } + else + { + clipboardStatus = t("ios.clipboard.empty") + } + case "clipboardUploaded": + clipboardStatus = t("ios.clipboard.uploaded") + case "deviceRevoked": + deviceActionStatus = t("ios.devices.revoked") + case "error": + // 复用现有 error 通知:剪贴板 / 吊销失败也走这里,分流到对应状态行。 + if let p = payload as? [String: Any] + { + let stage = p["stage"] as? String ?? "" + let msg = p["message"] as? String ?? "" + if stage == "clipboard" { clipboardStatus = t("ios.clipboard.failed") } + else if stage == "revoke" + { + deviceActionStatus = msg == "step_up_required" + ? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed") + } + else { status = "错误:\(msg)" } + } + case "sessionRotated": + // 引擎自刷致 broker 轮换 refresh → 更新 Keychain,重启免用失效旧 refresh。 + if let p = payload as? [String: Any], + let access = p["access_token"] as? String, + let refresh = p["refresh_token"] as? String + { + auth?.updateSession(accessToken: access, refreshToken: refresh) + } + case "authExpired": + // 引擎确证会话已失效(refresh 过期 / 吊销)→ 清 Keychain 回登录页。 + auth?.logout() + reset() + default: + break + } + } + + private func resolve(id: Int, ok: Bool, value: Any?) + { + webView?.evaluateJavaScript("window.__cdropEngineResolve(\(id), \(ok), \(jsonValue(value)));") + } +} + +// MARK: - 发送侧自定义 scheme(cdrop-file) + +extension EngineController: WKURLSchemeHandler +{ + // 发送侧:原生把暂存的待发文件经 cdrop-file:// 喂给引擎(JS fetch 它得到字节, + // 见 PLAN §2 / R-iOS-4)。v1 整文件读入内存返回(小 / 中文件可行);大文件改分块 + // didReceive 流式以避 jetsam = R-iOS-4 优化点。 + func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) + { + guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id] + else + { + respond(urlSchemeTask, status: 404) + return + } + guard let data = try? Data(contentsOf: fileURL) + else + { + respond(urlSchemeTask, status: 404) + return + } + let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/octet-stream", + "Content-Length": "\(data.count)", + ])! + urlSchemeTask.didReceive(resp) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) + { + // 无长任务(v1 同步返回),无需处理。 + } + + private func respond(_ task: any WKURLSchemeTask, status: Int) + { + guard let url = task.request.url, + let resp = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil) + else { return } + task.didReceive(resp) + task.didFinish() + } +} + +// MARK: - 通知解析(手动容忍,见 DeviceItem 注释) + +extension EngineController +{ + // 桥来的数值经 WKWebView 后可能是 NSNumber / Int / Double / Bool,任一字段类型不符都 + // 不应让整组失败。下列取值器逐字段宽松转换,缺失给默认。 + static func parseDevice(_ d: [String: Any]) -> DeviceItem? + { + guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil } + return DeviceItem(name: name, + type: type, + online: boolOf(d["online"]), + lastSeen: doubleOf(d["lastSeen"])) + } + + static func parseTransfer(_ t: [String: Any]) -> TransferItem? + { + guard let sessionId = t["sessionId"] as? String else { return nil } + let ice = t["ice"] as? [String: Any] + return TransferItem(sessionId: sessionId, + direction: t["direction"] as? String ?? "outgoing", + fileName: t["fileName"] as? String ?? "", + fileSize: intOf(t["fileSize"]), + state: t["state"] as? String ?? "PENDING", + mode: t["mode"] as? String, + peerName: t["peerName"] as? String ?? "", + phase: t["phase"] as? String, + bytesTransferred: t["bytesTransferred"].map { intOf($0) }, + bytesPerSec: t["bytesPerSec"].map { doubleOf($0) }, + iceConn: ice?["conn"] as? String, + iceLocal: ice?["local"] as? String, + iceRemote: ice?["remote"] as? String) + } + + static func boolOf(_ v: Any?) -> Bool + { + if let b = v as? Bool { return b } + if let n = v as? NSNumber { return n.boolValue } + return false + } + + static func intOf(_ v: Any?) -> Int + { + if let i = v as? Int { return i } + if let n = v as? NSNumber { return n.intValue } + if let d = v as? Double { return Int(d) } + return 0 + } + + static func doubleOf(_ v: Any?) -> Double + { + if let d = v as? Double { return d } + if let n = v as? NSNumber { return n.doubleValue } + if let i = v as? Int { return Double(i) } + return 0 + } +} + +// MARK: - 编码助手 + +private extension EngineController +{ + func decodeBase64(_ v: Any?) -> Data + { + guard let s = v as? String, let d = Data(base64Encoded: s) else { return Data() } + return d + } + + func jsonString(_ obj: [String: Any]) -> String? + { + guard JSONSerialization.isValidJSONObject(obj), + let d = try? JSONSerialization.data(withJSONObject: obj), + let s = String(data: d, encoding: .utf8) + else { return nil } + return s + } + + // 把任意值编码成可嵌入 JS 的字面量(字符串走 JSON 转义;nil → null)。借数组包裹再 + // 剥括号,绕过 JSONSerialization 不接受顶层标量的限制。 + func jsonValue(_ v: Any?) -> String + { + guard let v = v else { return "null" } + guard let d = try? JSONSerialization.data(withJSONObject: [v]), + let s = String(data: d, encoding: .utf8) + else { return "null" } + return String(s.dropFirst().dropLast()) + } + + func jsString(_ s: String) -> String + { + return jsonValue(s) + } + + // 引擎 boot 注入用:会话 JSON(无会话则 null)、设备名、api_base。 + func sessionJSON() -> String + { + guard let s = auth?.session else { return "null" } + return "{ access_token: \(jsString(s.accessToken)), " + + "refresh_token: \(jsString(s.refreshToken)), " + + "user: { id: \(jsString(s.user.id)), name: \(jsString(s.user.name)) } }" + } + + func currentDeviceName() -> String + { + return auth?.session?.deviceName ?? "iPhone" + } + + func apiBaseForBoot() -> String + { + return ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? "" + } +} diff --git a/ios/CDrop/Sources/Engine/EngineWebView.swift b/ios/CDrop/Sources/Engine/EngineWebView.swift new file mode 100644 index 0000000..0b0bc16 --- /dev/null +++ b/ios/CDrop/Sources/Engine/EngineWebView.swift @@ -0,0 +1,19 @@ +import SwiftUI +import WebKit + +// 把 EngineController 持有的离屏 WKWebView 桥进 SwiftUI 视图树(保持存活)。无可见 UI +// 用途——引擎由 controller 经 JS 桥驱动(见 ios/PLAN.md arch A)。 +struct EngineWebView: UIViewRepresentable +{ + let controller: EngineController + + func makeUIView(context: Context) -> WKWebView + { + return controller.makeWebView() + } + + func updateUIView(_ uiView: WKWebView, context: Context) + { + // 无需更新。 + } +} diff --git a/ios/CDrop/Sources/I18n.swift b/ios/CDrop/Sources/I18n.swift new file mode 100644 index 0000000..cf6b92e --- /dev/null +++ b/ios/CDrop/Sources/I18n.swift @@ -0,0 +1,66 @@ +import Foundation + +// i18n:读 app bundle 内的 .json(由 web 单一真源经 web/scripts/emit-i18n.mjs 产出, +// 强约束②,见 ios/PLAN.md §5)。跟随系统语言、回退 zh-CN。各 JSON 已含完整回退(缺失键 +// 在产出时已并入 zh-CN 值),故 loader 只需选对 locale。 +enum I18n +{ + private static let dict: [String: String] = load() + + static func t(_ key: String) -> String + { + return dict[key] ?? key + } + + // 插值变体,与 web t(key, vars) 同形:占位符 {{name}}。catalog 里带占位的键(如 + // settings.lastSeen 的 {{time}})须经此填充,否则字面量 {{...}} 会漏到 UI。 + static func t(_ key: String, _ args: [String: String]) -> String + { + var s = dict[key] ?? key + for (name, value) in args + { + s = s.replacingOccurrences(of: "{{\(name)}}", with: value) + } + return s + } + + private static func load() -> [String: String] + { + guard let url = resourceURL(preferredLocale()) ?? resourceURL("zh-CN"), + let data = try? Data(contentsOf: url), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return [:] } + return obj + } + + private static func resourceURL(_ loc: String) -> URL? + { + return Bundle.main.url(forResource: loc, withExtension: "json", subdirectory: "i18n") + ?? Bundle.main.url(forResource: loc, withExtension: "json") + } + + private static func preferredLocale() -> String + { + for lang in Locale.preferredLanguages + { + if lang.hasPrefix("zh-Hant") || lang.hasPrefix("zh-TW") || lang.hasPrefix("zh-HK") + { + return "zh-TW" + } + if lang.hasPrefix("zh") { return "zh-CN" } + if lang.hasPrefix("en") { return "en-US" } + } + return "zh-CN" + } +} + +// 全局简写,与 web 的 t() 对齐。 +func t(_ key: String) -> String +{ + return I18n.t(key) +} + +func t(_ key: String, _ args: [String: String]) -> String +{ + return I18n.t(key, args) +} diff --git a/ios/CDrop/Sources/Info.plist b/ios/CDrop/Sources/Info.plist new file mode 100644 index 0000000..46da4f3 --- /dev/null +++ b/ios/CDrop/Sources/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + zh-Hans + CFBundleDisplayName + Commilitia Drop + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + zh-Hans + zh-Hant + en + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSBonjourServices + + _cdrop._tcp + + NSCameraUsageDescription + Commilitia Drop 使用相机扫描二维码登录新设备。 + NSLocalNetworkUsageDescription + Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。 + UIFileSharingEnabled + + UILaunchScreen + + + diff --git a/ios/CDrop/Sources/QuickLookPreview.swift b/ios/CDrop/Sources/QuickLookPreview.swift new file mode 100644 index 0000000..06e6f85 --- /dev/null +++ b/ios/CDrop/Sources/QuickLookPreview.swift @@ -0,0 +1,72 @@ +import QuickLook +import SwiftUI + +// 预览项包装:URL 本身不是 Identifiable,sheet(item:) 需要它。 +struct PreviewItem: Identifiable +{ + let id = UUID() + let url: URL +} + +// 文件预览 sheet:QuickLookView 外套 NavigationStack + toolbar「完成」按钮,供「收到的 +// 文件」与传输详情两处共用。@Environment(\.dismiss) 关闭 sheet 即清空外层 item 绑定。 +struct FilePreviewSheet: View +{ + @Environment(\.dismiss) private var dismiss + let item: PreviewItem + + var body: some View + { + NavigationStack + { + QuickLookView(url: item.url) + .ignoresSafeArea() + .navigationTitle(item.url.lastPathComponent) + .navigationBarTitleDisplayMode(.inline) + .toolbar + { + ToolbarItem(placement: .topBarTrailing) + { + Button(t("ios.files.done")) { dismiss() } + } + } + } + } +} + +// QLPreviewController 的纯 SwiftUI 包装(无内建关闭入口)。关闭交给外层 SwiftUI +// NavigationStack 的 toolbar「完成」按钮——QL 自身的轻点切换 chrome 只影响它自己的工具 +// 栏,不动外层导航栏,故 Done 始终可达(上轮用 UINavigationController 内建 Done 不可靠: +// QL 全屏时把它一起藏了)。 +struct QuickLookView: UIViewControllerRepresentable +{ + let url: URL + + func makeUIViewController(context: Context) -> QLPreviewController + { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ controller: QLPreviewController, context: Context) + { + controller.reloadData() + } + + func makeCoordinator() -> Coordinator { Coordinator(url: url) } + + final class Coordinator: NSObject, QLPreviewControllerDataSource + { + let url: URL + init(url: URL) { self.url = url } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + + func previewController(_ controller: QLPreviewController, + previewItemAt index: Int) -> QLPreviewItem + { + return url as NSURL + } + } +} diff --git a/ios/CDrop/Sources/Resources/i18n/en-US.json b/ios/CDrop/Sources/Resources/i18n/en-US.json new file mode 100644 index 0000000..bf09a9a --- /dev/null +++ b/ios/CDrop/Sources/Resources/i18n/en-US.json @@ -0,0 +1,358 @@ +{ + "app.brand": "Commilitia Drop", + "deviceType.browser": "Browser", + "deviceType.macos": "macOS client", + "deviceType.windows": "Windows client", + "deviceType.linux": "Linux client", + "deviceType.ios": "iOS client", + "deviceType.shortcut": "Shortcut", + "app.connected": "Online", + "app.connecting": "Connecting…", + "app.disconnectedTitle": "Reconnecting", + "app.disconnectedHint": "Lost connection to the server — retrying automatically. No need to refresh.", + "auth.sessionLost.title": "Signed out", + "auth.sessionLost.body": "This device's login was revoked or expired. Please sign in again.", + "nav.accountMenu": "Account menu", + "nav.thisDeviceLabel": "This device: ", + "nav.settings": "Settings", + "nav.signOut": "Sign out", + "nav.backToHome": "← Home", + "nav.language": "语言 / Language", + "nav.theme": "主题 / Theme", + "nav.theme.light": "Light", + "nav.theme.dark": "Dark", + "nav.theme.system": "Follow system", + "nav.themeToggle": "Toggle theme", + "home.greeting": "Hello, {{name}}", + "home.deviceList.title": "Devices", + "home.deviceList.onlineSuffix": "({{count}} online)", + "home.deviceList.showOffline": "Show offline ({{count}})", + "home.deviceList.hideOffline": "Hide offline", + "home.deviceList.empty": "No peer devices yet. Open the app in another tab with a different device name (Settings → Device name) to see it here.", + "home.tabs.file": "File", + "home.tabs.message": "Message", + "home.tabs.clipboard": "Clipboard", + "home.file.dropzone": "Drop a file here or click to browse", + "home.file.selectedSize": "{{name}} ({{size}})", + "home.file.rejected": "File rejected", + "home.cta.selectDeviceAndFile": "Select a device and a file", + "home.cta.selectFile": "Select a file", + "home.cta.selectDevice": "Select a device", + "home.cta.sendTo": "Send to {{name}}", + "home.message.placeholder": "Type a message to {{name}}…", + "home.message.placeholderEmpty": "Select a device first", + "home.message.hint": "Enter to send, Shift+Enter for newline. Max 4 KB.", + "home.message.title": "Messages", + "home.message.countSuffix": "({{count}})", + "home.message.clearAll": "Clear all", + "home.transfer.active": "Active", + "home.transfer.history": "Recent transfers", + "home.clipboard.cloudTitle": "Latest from cloud", + "home.clipboard.cloudFrom": "From {{name}} · {{time}}", + "home.clipboard.cloudFromSelf": "From this device · {{time}}", + "home.clipboard.cloudEmpty": "Nothing synced yet.", + "home.clipboard.copyToLocal": "Copy to local clipboard", + "home.clipboard.copyToLocalSuccess": "Copied to local clipboard", + "home.clipboard.uploadTitle": "Sync local clipboard", + "home.clipboard.uploadHint": "Plain text only, max 64 KB. Styles (bold / color) are stripped.", + "home.clipboard.uploadButton": "Upload local clipboard", + "home.clipboard.uploading": "Uploading…", + "home.clipboard.uploadEmpty": "Local clipboard is empty", + "home.clipboard.refresh": "Refresh", + "home.clipboard.clear": "Clear cloud", + "home.clipboard.clearConfirm": "Clear the cloud clipboard? All devices will see empty content.", + "home.clipboard.clearSuccess": "Cloud clipboard cleared", + "home.clipboard.hidden": "Content hidden — click Reveal to show", + "home.clipboard.reveal": "Reveal", + "home.clipboard.hide": "Hide", + "setup.title": "Name this device", + "setup.help": "The name is shown to your other devices. Avoid spaces, ≤32 chars.", + "setup.field": "Device name", + "setup.save": "Save and continue", + "login.title": "Sign in", + "login.dev.help": "Dev mode: pick any user identifier. Two tabs with the same identifier simulate two devices of one user.", + "login.dev.field": "Dev user id", + "login.dev.continue": "Continue", + "login.prod.help": "Sign in via Casdoor.", + "login.prod.button": "Sign in with Casdoor", + "oauth.failed": "Sign-in failed", + "oauth.signingIn": "Signing in…", + "oauth.missingParams": "Missing 'code' or 'state' query parameter", + "qr.entry.fromLogin": "Sign in with a QR code", + "qr.entry.scanNewDevice": "Scan to link a device", + "qr.scan.title": "Scan to link a device", + "qr.scan.subtitle": "Point the code shown on the other device at the frame. Approval stays on this signed-in device.", + "qr.scan.starting": "Starting the camera…", + "qr.scan.aiming": "Line the code up inside the frame", + "qr.scan.foreign": "That isn't a sign-in code for this site. Aim at the code from “Sign in with a QR code” on the new device.", + "qr.scan.noCamera": "This device has no camera available. Use a phone or tablet with a camera to scan instead.", + "qr.scan.denied": "Camera access was blocked. Allow the camera in your browser's site settings, then reopen this page.", + "qr.scan.error": "Couldn't open the camera. Try again, or scan from a device with a camera.", + "qr.scan.howto1": "On the new device, open “Sign in with a QR code” to show a code.", + "qr.scan.howto2": "Line that code up in the frame above — approval starts automatically.", + "qr.scan.back": "Back", + "qr.show.title": "Sign in with a QR code", + "qr.show.subtitle": "Name this device, then scan the code from a signed-in phone to let it into your account.", + "qr.show.nameHint": "Shown to your other devices. ASCII characters only.", + "qr.show.generate": "Generate code", + "qr.show.scanTitle": "Scan to approve", + "qr.show.howto1": "Open the camera or cdrop on another signed-in device.", + "qr.show.howto2": "Scan the code above and approve this device.", + "qr.show.waiting": "Waiting for approval…", + "qr.show.approved": "Approved — signing you in…", + "qr.show.expired": "This code has expired. Generate a new one and scan again.", + "qr.show.denied": "This request was declined. If that was you, generate a new code and try again.", + "qr.show.regenerate": "Generate a new code", + "qr.show.back": "Back to home", + "qr.show.backToName": "Back", + "qr.approve.title": "Approve a new device", + "qr.approve.subtitle": "Confirm this device may sign in to your account. Check the device and origin first.", + "qr.approve.loading": "Reading device details…", + "qr.approve.badLink": "This approval link is incomplete. Generate a new code on the device and scan it again.", + "qr.approve.signInFirst": "Sign in to your account first, then approve this device.", + "qr.approve.trustLabel": "Trust duration", + "qr.approve.trust.title": "Trust this device (7 days)", + "qr.approve.trust.hint": "Skip approval for 7 days, renewed on each use.", + "qr.approve.once.title": "Just this once (1 hour)", + "qr.approve.once.hint": "Signed in for 1 hour, then scan again.", + "qr.approve.scopeFull": "This device signs fully into your account for 7 days: it can send and receive files and messages, and manage your account and devices.", + "qr.approve.scopeGuest": "Signs in as a limited guest — it can send and receive files and messages, but can't change your account or approve other devices; expires after 1 hour.", + "qr.approve.nativeFull": "This is a native app device — it signs in with full access (trusted, valid for 7 days). Native apps don't support limited guest sessions.", + "qr.approve.trustContinue": "Trust & Continue", + "qr.approve.approve": "Approve sign-in", + "qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.", + "qr.approve.stepUp.button": "Verify and approve", + "qr.approve.stepUp.rejected": "Identity verification didn't pass or has expired. Verify again to confirm it's you, then approve.", + "qr.approve.deny": "Decline", + "qr.approve.doneApprovedTitle": "Approved", + "qr.approve.doneApproved": "This device is now linked to your account. You can send and receive files on it.", + "qr.approve.doneDeniedTitle": "Declined", + "qr.approve.doneDenied": "The request was declined. That device won't sign in to your account.", + "qr.approve.expiredTitle": "Code expired", + "qr.approve.expired": "This code is no longer valid. Generate a new one on the device and scan again.", + "qr.approve.errorTitle": "Something went wrong", + "qr.approve.error": "That didn't go through. Try again, or generate a new code on the device.", + "qr.approve.backHome": "Back to home", + "settings.title": "Settings", + "settings.currentDevice.title": "This device", + "settings.deviceName.field": "Device name", + "settings.deviceName.save": "Save", + "settings.deviceName.unchanged": "Name is unchanged", + "settings.deviceName.empty": "Name cannot be empty", + "settings.deviceName.asciiOnly": "Device name must use ASCII characters only (letters, numbers, symbols).", + "settings.deviceName.success": "Device name updated", + "settings.unregister.current": "Unregister this device", + "settings.unregister.currentHint": "Removes this device's registration and signs you out.", + "settings.unregister.currentConfirm": "Unregistering this device will remove its registration and sign you out. Continue?", + "settings.error.delete": "Action failed: {{message}}", + "settings.guest.badge": "Limited guest", + "settings.guest.title": "This device is a limited guest", + "settings.guest.body": "This device is signed in as a limited guest: it can send and receive files, messages, and clipboard, but cannot manage the account, remove devices, or authorize new ones. To get full access, sign in again on your main device.", + "settings.sessions.title": "Login sessions", + "settings.sessions.hint": "Every device currently signed in to this account — browser, desktop, and mobile clients; online ones are listed first. Signing a device out ends its session immediately; it must sign in again on that device to continue.", + "settings.sessions.guestNote": "Limited guests can't manage login sessions. To manage them, sign in fully on your main device.", + "settings.sessions.empty": "No other login sessions.", + "settings.sessions.loadError": "Failed to load login sessions.", + "settings.sessions.retry": "Retry", + "settings.sessions.current": "This device", + "settings.sessions.scopeFull": "Full", + "settings.sessions.scopeGuest": "Limited guest", + "settings.sessions.kind.oidc": "Account login", + "settings.sessions.kind.self": "Local account", + "settings.sessions.kind.guest": "Scanned guest", + "settings.sessions.lastUsed": "Last active {{time}}", + "settings.sessions.neverUsed": "Not active yet", + "settings.sessions.signOut": "Sign out", + "settings.sessions.signingOut": "Signing out…", + "settings.sessions.signOutCurrent": "Sign out this device", + "settings.sessions.confirmOther": "Sign out device \"{{name}}\"? Its session will be invalidated immediately and it must sign in again.", + "settings.sessions.confirmCurrent": "Signing out this device is the same as logging out. Continue?", + "settings.sessions.revoked": "Signed that device out", + "settings.sessions.stepUpRejected": "Identity verification failed or expired. Please try again.", + "settings.online": "Online", + "settings.offline": "Offline", + "settings.lastSeen": "Last seen {{time}}", + "settings.push.title": "Push notifications", + "settings.push.hint": "When this page is closed, new files, messages, and transfer results arrive as system notifications; while it's open, you'll see in-app alerts instead.", + "settings.push.enable": "Enable push", + "settings.push.enabled": "Enabled", + "settings.push.disable": "Turn off", + "settings.push.deniedHint": "Notifications are blocked by the browser. Allow them in this site's settings, then try again.", + "settings.push.denied": "Notification permission denied", + "settings.push.enableOk": "Push notifications enabled", + "settings.push.disableOk": "Push notifications turned off", + "settings.push.failed": "Couldn't enable push. Please try again.", + "notify.incoming.title": "Incoming file", + "notify.incoming.body": "{{sender}} is sending {{file}}", + "notify.transfer.doneTitle": "Transfer complete", + "notify.transfer.failedTitle": "Transfer failed", + "settings.account.title": "Account", + "settings.account.signOut": "Sign out", + "settings.desktop.title": "Desktop", + "settings.desktop.clipboardSync": "Auto-sync clipboard", + "settings.desktop.clipboardSyncHint": "Copies are uploaded to the cloud, and clipboard updates from your other devices are applied here.", + "settings.desktop.launchAtLogin": "Launch at login", + "settings.desktop.launchAtLoginHint": "Start cdrop in the background after you sign in (lives in the menu bar).", + "settings.desktop.downloadDir": "Download folder", + "settings.desktop.downloadDirHint": "Received files are saved here. Leave unset to use the system Downloads folder.", + "settings.desktop.downloadDirChoose": "Change…", + "settings.desktop.downloadDirReset": "Reset to default", + "settings.desktop.downloadDirPicker": "Choose download folder", + "settings.desktop.ttlNote": "For security, cloud clipboard content is cleared automatically after 5 minutes.", + "settings.shortcut.title": "iOS Shortcut", + "settings.shortcut.intro": "Issue a long-lived, clipboard-only, revocable token for the iOS Shortcuts app. Even if leaked it can only read/write the clipboard — nothing else.", + "settings.shortcut.labelField": "Label", + "settings.shortcut.labelPlaceholder": "e.g. My iPhone", + "settings.shortcut.issue": "Issue", + "settings.shortcut.empty": "No tokens issued yet.", + "settings.shortcut.loadError": "Failed to load tokens.", + "settings.shortcut.retry": "Retry", + "settings.shortcut.revoke": "Revoke", + "settings.shortcut.revoking": "Revoking…", + "settings.shortcut.revokeConfirm": "Revoking immediately disables any Shortcut using this token. Continue?", + "settings.shortcut.revoked": "Revoked", + "settings.shortcut.revokedToast": "Token revoked", + "settings.shortcut.expired": "Expired", + "settings.shortcut.expiresAt": "Expires {{date}}", + "settings.shortcut.lastUsed": "Last used {{time}}", + "settings.shortcut.neverUsed": "Never used", + "settings.shortcut.unavailable": "Shortcut tokens are not enabled on the server.", + "settings.shortcut.tooMany": "You have reached the active-token limit. Revoke an old one first.", + "settings.shortcut.issueError": "Failed: {{message}}", + "settings.shortcut.issuedTitle": "Token issued", + "settings.shortcut.onceWarning": "This token is shown only once. Copy it now — you won't be able to see it again after closing.", + "settings.shortcut.tokenLabel": "Token (Bearer)", + "settings.shortcut.copy": "Copy", + "settings.shortcut.copyFailed": "Copy failed — select and copy manually.", + "settings.shortcut.copied": "Copied", + "settings.shortcut.baseLabel": "Server address", + "settings.shortcut.guideTitle": "Set up on iOS (manual sync)", + "settings.shortcut.guideIntro": "Build two shortcuts in the iPhone Shortcuts app, filling in the server address and token above on first run:", + "settings.shortcut.guideDeviceName": "Add header X-Device-Name: your-device-name (ASCII only) to both shortcuts so it shows as one device in the list.", + "settings.shortcut.guidePullTitle": "Pull (cloud → local clipboard)", + "settings.shortcut.guidePull1": "'Get Contents of URL': set the URL to your-server-address + /api/clipboard, method GET, add header Authorization: Bearer your-token.", + "settings.shortcut.guidePull2": "'Get Dictionary Value': key content.", + "settings.shortcut.guidePull3": "If non-empty, 'Copy to Clipboard'; optionally add 'Show Notification'.", + "settings.shortcut.guidePushTitle": "Push (local clipboard → cloud)", + "settings.shortcut.guidePush1": "'Get Clipboard'; stop if empty.", + "settings.shortcut.guidePush2": "'Get Contents of URL': same URL, method PUT, headers Authorization: Bearer your-token and Content-Type: application/json, JSON body: {\"content\": clipboard, \"content_type\": \"text/plain\"}.", + "settings.shortcut.guidePush3": "Optionally add 'Show Notification'.", + "settings.shortcut.guideToleranceNote": "A single run may fail on a flaky network — just retry. The automatic polling version (next phase) retries silently in the background.", + "settings.shortcut.done": "Done", + "transfer.savedTo": "Saved to {{path}}", + "transfer.saveFailed": "Failed to save file: {{error}}", + "transfer.action.cancel": "Cancel", + "transfer.action.delete": "Delete", + "transfer.action.relayNow": "Relay now", + "transfer.action.details": "Details", + "transfer.state.PENDING": "Pending", + "transfer.state.ACCEPTED": "Accepted", + "transfer.state.P2P_ACTIVE": "Transferring", + "transfer.state.RELAY_ACTIVE": "Relaying", + "transfer.state.DONE": "Done", + "transfer.state.FAILED": "Failed", + "transfer.state.CANCELLED": "Cancelled", + "transfer.mode.p2p": "P2P", + "transfer.mode.relay": "Relay", + "transfer.phase.initializing": "Initializing…", + "transfer.phase.waiting_accept": "Waiting for the other side to accept…", + "transfer.phase.ice_gathering": "Gathering network candidates…", + "transfer.phase.ice_checking": "Establishing direct tunnel…", + "transfer.phase.ice_connected": "Tunnel established", + "transfer.phase.dc_open": "Channel open", + "transfer.phase.transferring": "Transferring", + "transfer.phase.completing": "Finalizing…", + "transfer.phase.fallback_pending": "Falling back to relay…", + "transfer.phase.relay_uploading": "Uploading via relay", + "transfer.phase.relay_downloading": "Downloading via relay", + "transfer.stalled": "Looks stalled ({{seconds}}s without progress)", + "transfer.bytesProgress": "{{sent}} / {{total}}", + "transfer.bytesRate": "{{rate}}/s", + "transfer.debug.toggle": "Debug", + "transfer.debug.iceGathering": "Gathering", + "transfer.debug.iceConnection": "Connection", + "transfer.debug.candidatesLocal": "Local candidates", + "transfer.debug.candidatesRemote": "Remote candidates", + "transfer.debug.selectedPair": "Selected pair", + "transfer.debug.noPair": "No nominated pair yet", + "transfer.debug.candidatesLine": "host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}", + "transfer.debug.dataChannel": "Data channel", + "transfer.debug.dcLine": "{{state}} · buffered {{buffered}}", + "time.justNow": "just now", + "time.secondsAgo": "{{n}}s ago", + "time.minutesAgo": "{{n}}m ago", + "time.hoursAgo": "{{n}}h ago", + "common.dismiss": "Dismiss", + "common.cancel": "Cancel", + "common.confirm": "Confirm", + "errors.messageEmpty": "Message is empty", + "errors.messageOverflow": "Message exceeds 4 KB; send a file instead", + "errors.noReceiver": "No receiver selected", + "errors.devTokenMissing": "Dev mode: VITE_CDROP_DEV_TOKEN is not set in .env.local", + "errors.noAccessToken": "No access token; user must log in", + "errors.clipboardUnavailable": "Browser clipboard API unavailable (requires HTTPS + permission)", + "errors.clipboardOverflow": "Content too large; exceeds {{max}} byte limit", + "errors.clipboardWriteFailed": "Failed to write to local clipboard: {{message}}", + "ios.tab.transfer": "Transfers", + "ios.tab.devices": "Devices", + "ios.tab.settings": "Settings", + "ios.login.generating": "Generating code…", + "ios.login.waiting": "Waiting for approval", + "ios.login.guide": "Scan to approve from another signed-in device", + "ios.login.expired": "Code expired — please try again", + "ios.login.failed": "Sign-in failed — please try again", + "ios.login.refresh": "Refresh QR Code", + "ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.", + "ios.send.pickDevice": "Choose a device", + "ios.transfer.sending": "Sending", + "ios.settings.engine": "Engine", + "ios.settings.status": "Status", + "ios.settings.device": "Device", + "ios.settings.clipboard": "Clipboard", + "ios.settings.clipboardNote": "Upload pushes this device's clipboard to your others; Pull writes the latest cloud clipboard.", + "ios.clipboard.upload": "Upload Clipboard", + "ios.clipboard.pull": "Pull Clipboard", + "ios.clipboard.uploading": "Uploading…", + "ios.clipboard.pulling": "Pulling…", + "ios.clipboard.uploaded": "Uploaded to cloud clipboard", + "ios.clipboard.pulled": "Written to clipboard", + "ios.clipboard.empty": "Clipboard is empty", + "ios.clipboard.failed": "Clipboard sync failed", + "ios.devices.remove": "Remove Device", + "ios.devices.thisDevice": "This device", + "ios.devices.revoked": "Device removed", + "ios.devices.revokeFailed": "Remove failed", + "ios.devices.revokeStepUp": "Re-verify on the web to remove this device", + "ios.engine.disconnected": "Disconnected", + "ios.engine.ready": "Ready", + "ios.transfer.empty": "No transfers yet", + "ios.transfer.incoming": "Receive", + "ios.transfer.outgoing": "Send", + "ios.send.noDevices": "No online devices to send to", + "ios.devices.empty": "No devices yet", + "ios.detail.title": "Transfer Details", + "ios.detail.direction": "Direction", + "ios.detail.peer": "Peer", + "ios.detail.state": "State", + "ios.detail.phase": "Phase", + "ios.detail.channel": "Channel", + "ios.detail.size": "Size", + "ios.detail.progress": "Progress", + "ios.detail.speed": "Speed", + "ios.settings.account": "Account", + "ios.settings.user": "User", + "ios.settings.logout": "Sign Out", + "ios.settings.deviceName": "Device Name", + "ios.settings.deviceNameNote": "Renaming takes effect on next sign-in", + "ios.settings.deviceCount": "Known Devices", + "ios.settings.signaling": "Signaling", + "ios.settings.presenceEvents": "Presence Events", + "ios.settings.reconnecting": "Reconnecting", + "ios.settings.lastLog": "Last Log", + "ios.tab.files": "Files", + "ios.files.title": "Received Files", + "ios.files.empty": "No files received yet", + "ios.files.share": "Share", + "ios.files.done": "Done", + "ios.detail.openFile": "Open File" +} diff --git a/ios/CDrop/Sources/Resources/i18n/zh-CN.json b/ios/CDrop/Sources/Resources/i18n/zh-CN.json new file mode 100644 index 0000000..6f3d9bc --- /dev/null +++ b/ios/CDrop/Sources/Resources/i18n/zh-CN.json @@ -0,0 +1,358 @@ +{ + "app.brand": "Commilitia Drop", + "deviceType.browser": "浏览器", + "deviceType.macos": "macOS 客户端", + "deviceType.windows": "Windows 客户端", + "deviceType.linux": "Linux 客户端", + "deviceType.ios": "iOS 客户端", + "deviceType.shortcut": "快捷指令", + "app.connected": "已连接", + "app.connecting": "正在连接…", + "app.disconnectedTitle": "正在重连", + "app.disconnectedHint": "与服务器的连接已断开,正在自动重连,无需刷新页面。", + "auth.sessionLost.title": "登录已失效", + "auth.sessionLost.body": "此设备的登录凭据已被注销或过期,请重新登录。", + "nav.accountMenu": "账号菜单", + "nav.thisDeviceLabel": "本机:", + "nav.settings": "设置", + "nav.signOut": "退出登录", + "nav.backToHome": "← 主页", + "nav.language": "语言 / Language", + "nav.theme": "主题 / Theme", + "nav.theme.light": "浅色", + "nav.theme.dark": "深色", + "nav.theme.system": "跟随系统", + "nav.themeToggle": "切换主题", + "home.greeting": "你好,{{name}}", + "home.deviceList.title": "设备", + "home.deviceList.onlineSuffix": "({{count}} 台在线)", + "home.deviceList.showOffline": "显示离线设备({{count}})", + "home.deviceList.hideOffline": "隐藏离线设备", + "home.deviceList.empty": "暂无其他设备。在另一标签页或设备上以相同账户登录,并设置一个不同的设备名(设置 → 设备名称),即可在此处看到它。", + "home.tabs.file": "文件", + "home.tabs.message": "消息", + "home.tabs.clipboard": "剪贴板", + "home.file.dropzone": "拖放文件到此处或点击选择", + "home.file.selectedSize": "{{name}}({{size}})", + "home.file.rejected": "文件已拒绝", + "home.cta.selectDeviceAndFile": "请选择设备与文件", + "home.cta.selectFile": "请选择文件", + "home.cta.selectDevice": "请选择设备", + "home.cta.sendTo": "发送到 {{name}}", + "home.message.placeholder": "发送消息到 {{name}}…", + "home.message.placeholderEmpty": "请先选择设备", + "home.message.hint": "回车发送,Shift + 回车换行,最长4 KB。", + "home.message.title": "消息", + "home.message.countSuffix": "({{count}})", + "home.message.clearAll": "全部清除", + "home.transfer.active": "进行中", + "home.transfer.history": "最近的传输", + "home.clipboard.cloudTitle": "云端最新", + "home.clipboard.cloudFrom": "来自 {{name}} · {{time}}", + "home.clipboard.cloudFromSelf": "来自本机 · {{time}}", + "home.clipboard.cloudEmpty": "尚未同步任何内容。", + "home.clipboard.copyToLocal": "复制到本机剪贴板", + "home.clipboard.copyToLocalSuccess": "已复制到本机剪贴板", + "home.clipboard.uploadTitle": "同步本机剪贴板", + "home.clipboard.uploadHint": "仅纯文本,最大64 KB;样式(粗体/颜色等)会被剥除。", + "home.clipboard.uploadButton": "上传本机剪贴板", + "home.clipboard.uploading": "上传中…", + "home.clipboard.uploadEmpty": "本机剪贴板为空", + "home.clipboard.refresh": "刷新", + "home.clipboard.clear": "清空云端", + "home.clipboard.clearConfirm": "确认清空云端剪贴板?所有设备将看到空内容。", + "home.clipboard.clearSuccess": "已清空云端剪贴板", + "home.clipboard.hidden": "内容已隐藏 · 点击“显示”查看", + "home.clipboard.reveal": "显示", + "home.clipboard.hide": "隐藏", + "setup.title": "为本机命名", + "setup.help": "此名称会展示给你的其他设备。建议避免空格,长度不超过 32 字符。", + "setup.field": "设备名称", + "setup.save": "保存并继续", + "login.title": "登录", + "login.dev.help": "Dev模式:随意填写一个用户标识。同标识的两个标签页将模拟同一用户的两台设备。", + "login.dev.field": "Dev用户标识", + "login.dev.continue": "继续", + "login.prod.help": "通过Casdoor登录。", + "login.prod.button": "使用Casdoor登录", + "oauth.failed": "登录失败", + "oauth.signingIn": "正在登录…", + "oauth.missingParams": "缺少code或state查询参数", + "qr.entry.fromLogin": "扫码登录此设备", + "qr.entry.scanNewDevice": "扫码登录新设备", + "qr.scan.title": "扫码登录新设备", + "qr.scan.subtitle": "把另一台设备显示的二维码对准取景框。批准全程留在这台已登录设备上。", + "qr.scan.starting": "正在启动相机…", + "qr.scan.aiming": "把二维码对准取景框", + "qr.scan.foreign": "这不是本站的登录二维码。请对准新设备上“扫码登录此设备”生成的码。", + "qr.scan.noCamera": "这台设备没有可用的摄像头。请改用带相机的手机或平板扫码。", + "qr.scan.denied": "相机权限被拒绝。请在浏览器站点设置里允许使用相机,再重新打开此页。", + "qr.scan.error": "无法打开相机。请重试,或改用带相机的设备扫码。", + "qr.scan.howto1": "在新设备上打开“扫码登录此设备”,生成一张二维码。", + "qr.scan.howto2": "把那张二维码对准上方取景框,识别后会自动进入批准。", + "qr.scan.back": "返回", + "qr.show.title": "扫码登录此设备", + "qr.show.subtitle": "先给这台设备起个名字,再用已登录的手机扫码批准它接入你的账号。", + "qr.show.nameHint": "此名称会展示给你的其他设备,仅可使用 ASCII 字符。", + "qr.show.generate": "生成二维码", + "qr.show.scanTitle": "用手机扫码批准", + "qr.show.howto1": "在另一台已登录的设备上打开相机或 cdrop。", + "qr.show.howto2": "扫描上方二维码,按提示批准此设备。", + "qr.show.waiting": "等待批准…", + "qr.show.approved": "已批准,正在进入…", + "qr.show.expired": "二维码已过期。重新生成一张再扫。", + "qr.show.denied": "这次接入被拒绝。如确为你本人操作,重新生成二维码再试。", + "qr.show.regenerate": "重新生成二维码", + "qr.show.back": "返回主页", + "qr.show.backToName": "返回上一步", + "qr.approve.title": "批准新设备", + "qr.approve.subtitle": "确认让这台设备登录你的账号。请核对设备与来源无误。", + "qr.approve.loading": "正在读取设备信息…", + "qr.approve.badLink": "这个批准链接不完整。请在新设备上重新生成二维码再扫。", + "qr.approve.signInFirst": "先登录你的账号,再批准这台设备接入。", + "qr.approve.trustLabel": "信任时长", + "qr.approve.trust.title": "信任此设备(7 天)", + "qr.approve.trust.hint": "7 天内免再次批准,每次使用自动续期。", + "qr.approve.once.title": "仅此一次(1 小时)", + "qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。", + "qr.approve.scopeFull": "这台设备将完整登录你的账号、7 天内有效:可正常收发文件与消息,并能管理账号与设备。", + "qr.approve.scopeGuest": "以受限访客身份登录——可收发文件与消息,但不能更改账号、不能批准其他设备;1 小时后失效。", + "qr.approve.nativeFull": "这是一台原生应用设备,将以完整权限登录(信任此设备、7 天内有效)。原生应用不支持受限访客。", + "qr.approve.trustContinue": "信任并继续", + "qr.approve.approve": "批准登录", + "qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。", + "qr.approve.stepUp.button": "验证并批准", + "qr.approve.stepUp.rejected": "身份验证未通过或已过期。请再验证一次,确认是你本人后即可批准。", + "qr.approve.deny": "拒绝", + "qr.approve.doneApprovedTitle": "已批准", + "qr.approve.doneApproved": "这台设备已接入你的账号,现在即可在它上面收发文件。", + "qr.approve.doneDeniedTitle": "已拒绝", + "qr.approve.doneDenied": "这次接入已被拒绝,那台设备不会登录你的账号。", + "qr.approve.expiredTitle": "二维码已过期", + "qr.approve.expired": "这张二维码已失效。请在新设备上重新生成再扫。", + "qr.approve.errorTitle": "出错了", + "qr.approve.error": "操作没能完成。请重试,或在新设备上重新生成二维码。", + "qr.approve.backHome": "返回主页", + "settings.title": "设置", + "settings.currentDevice.title": "当前设备", + "settings.deviceName.field": "设备名称", + "settings.deviceName.save": "保存", + "settings.deviceName.unchanged": "新名称与当前一致", + "settings.deviceName.empty": "名称不能为空", + "settings.deviceName.asciiOnly": "设备名称只能使用 ASCII 字符(英文字母、数字、符号)。", + "settings.deviceName.success": "设备名称已更新", + "settings.unregister.current": "注销当前设备", + "settings.unregister.currentHint": "将退出登录并移除此设备的注册。", + "settings.unregister.currentConfirm": "注销当前设备会移除此设备的注册并退出登录,确认继续?", + "settings.error.delete": "操作失败:{{message}}", + "settings.guest.badge": "受限访客", + "settings.guest.title": "这台设备是受限访客", + "settings.guest.body": "本设备以受限访客身份登录:可正常收发文件、消息与剪贴板,但不能管理账号、移除设备或授权新设备。需要完整权限,请在你的主设备上重新登录。", + "settings.sessions.title": "登录会话", + "settings.sessions.hint": "这里列出当前登录此账号的所有设备,含浏览器、桌面与移动客户端;在线的排在上方。登出某台设备会立即结束它的登录,需在该设备上重新登录才能继续使用。", + "settings.sessions.guestNote": "受限访客无法管理登录会话;如需管理,请在你的主设备上完整登录。", + "settings.sessions.empty": "暂无其他登录会话。", + "settings.sessions.loadError": "登录会话加载失败。", + "settings.sessions.retry": "重试", + "settings.sessions.current": "本机", + "settings.sessions.scopeFull": "完整", + "settings.sessions.scopeGuest": "受限访客", + "settings.sessions.kind.oidc": "账号登录", + "settings.sessions.kind.self": "本地账号", + "settings.sessions.kind.guest": "扫码访客", + "settings.sessions.lastUsed": "最近活跃 {{time}}", + "settings.sessions.neverUsed": "尚未活跃", + "settings.sessions.signOut": "登出", + "settings.sessions.signingOut": "正在登出…", + "settings.sessions.signOutCurrent": "登出本机", + "settings.sessions.confirmOther": "确认登出设备“{{name}}”?该会话将立即失效,需重新登录。", + "settings.sessions.confirmCurrent": "登出本机会等同于退出登录,确认继续?", + "settings.sessions.revoked": "已登出该设备", + "settings.sessions.stepUpRejected": "身份验证未通过或已过期,请再试一次。", + "settings.online": "在线", + "settings.offline": "离线", + "settings.lastSeen": "上次活跃 {{time}}", + "settings.push.title": "推送通知", + "settings.push.hint": "页面关闭时,新文件、消息和传输结果会以系统通知提醒;页面打开时仍走应用内提示。", + "settings.push.enable": "启用推送", + "settings.push.enabled": "已启用", + "settings.push.disable": "关闭推送", + "settings.push.deniedHint": "通知权限已被浏览器拒绝,请在浏览器的站点设置中允许后重试。", + "settings.push.denied": "通知权限被拒绝", + "settings.push.enableOk": "已启用推送通知", + "settings.push.disableOk": "已关闭推送通知", + "settings.push.failed": "启用推送失败,请重试", + "notify.incoming.title": "收到文件", + "notify.incoming.body": "{{sender}} 发来 {{file}}", + "notify.transfer.doneTitle": "传输完成", + "notify.transfer.failedTitle": "传输失败", + "settings.account.title": "账号", + "settings.account.signOut": "退出登录", + "settings.desktop.title": "桌面", + "settings.desktop.clipboardSync": "剪贴板自动同步", + "settings.desktop.clipboardSyncHint": "复制即上传到云端,并接收其他设备的剪贴板更新。", + "settings.desktop.launchAtLogin": "开机自启", + "settings.desktop.launchAtLoginHint": "登录系统后自动在后台启动 cdrop(菜单栏常驻)。", + "settings.desktop.downloadDir": "下载目录", + "settings.desktop.downloadDirHint": "接收到的文件保存到此目录;留空则使用系统下载目录。", + "settings.desktop.downloadDirChoose": "更改…", + "settings.desktop.downloadDirReset": "恢复默认", + "settings.desktop.downloadDirPicker": "选择下载目录", + "settings.desktop.ttlNote": "出于安全考虑,云端剪贴板内容会在 5 分钟后自动清除。", + "settings.shortcut.title": "iOS 快捷指令", + "settings.shortcut.intro": "为 iOS“快捷指令”签发长效、仅限剪贴板、可随时吊销的专用令牌。即便令牌泄漏也只能读写剪贴板、无法触及其他数据。", + "settings.shortcut.labelField": "备注名称", + "settings.shortcut.labelPlaceholder": "例如:我的 iPhone", + "settings.shortcut.issue": "签发", + "settings.shortcut.empty": "尚未签发任何令牌。", + "settings.shortcut.loadError": "令牌列表加载失败。", + "settings.shortcut.retry": "重试", + "settings.shortcut.revoke": "吊销", + "settings.shortcut.revoking": "正在吊销…", + "settings.shortcut.revokeConfirm": "吊销后使用此令牌的快捷指令将立即失效,确认继续?", + "settings.shortcut.revoked": "已吊销", + "settings.shortcut.revokedToast": "令牌已吊销", + "settings.shortcut.expired": "已过期", + "settings.shortcut.expiresAt": "到期 {{date}}", + "settings.shortcut.lastUsed": "上次使用 {{time}}", + "settings.shortcut.neverUsed": "尚未使用", + "settings.shortcut.unavailable": "服务器未启用快捷指令令牌。", + "settings.shortcut.tooMany": "活跃令牌已达上限,请先吊销旧令牌。", + "settings.shortcut.issueError": "操作失败:{{message}}", + "settings.shortcut.issuedTitle": "令牌已签发", + "settings.shortcut.onceWarning": "令牌仅显示这一次,请立即复制保存;关闭后无法再次查看。", + "settings.shortcut.tokenLabel": "令牌(Bearer)", + "settings.shortcut.copy": "复制", + "settings.shortcut.copyFailed": "复制失败,请手动选择复制", + "settings.shortcut.copied": "已复制", + "settings.shortcut.baseLabel": "服务器地址", + "settings.shortcut.guideTitle": "在 iOS 上手动搭建(手动同步)", + "settings.shortcut.guideIntro": "在 iPhone 的“快捷指令”App 中新建两个指令,首次运行时填入上面的服务器地址与令牌:", + "settings.shortcut.guideDeviceName": "两个指令都加请求头 X-Device-Name: 你的设备名(仅 ASCII),让它在设备列表里统一显示。", + "settings.shortcut.guidePullTitle": "拉取(云端 → 本机剪贴板)", + "settings.shortcut.guidePull1": "“获取 URL 内容”:地址设为 你的服务器地址 加 /api/clipboard,方法 GET,请求头加 Authorization: Bearer 你的令牌。", + "settings.shortcut.guidePull2": "“获取词典值”:取键 content。", + "settings.shortcut.guidePull3": "内容非空时用“拷贝到剪贴板”写入,可再加“显示通知”提示已拉取。", + "settings.shortcut.guidePushTitle": "上传(本机剪贴板 → 云端)", + "settings.shortcut.guidePush1": "“获取剪贴板”;若为空则停止。", + "settings.shortcut.guidePush2": "“获取 URL 内容”:地址同上,方法 PUT,请求头加 Authorization: Bearer 你的令牌 与 Content-Type: application/json,请求体选 JSON:{\"content\": 剪贴板, \"content_type\": \"text/plain\"}。", + "settings.shortcut.guidePush3": "可加“显示通知”提示已上传。", + "settings.shortcut.guideToleranceNote": "网络不稳定时单次运行可能失败,重试即可;自动轮询版会在后台静默重试(下一阶段提供)。", + "settings.shortcut.done": "完成", + "transfer.savedTo": "已保存到 {{path}}", + "transfer.saveFailed": "保存文件失败:{{error}}", + "transfer.action.cancel": "取消", + "transfer.action.delete": "删除", + "transfer.action.relayNow": "立即中继", + "transfer.action.details": "详情", + "transfer.state.PENDING": "等待中", + "transfer.state.ACCEPTED": "已接受", + "transfer.state.P2P_ACTIVE": "传输中", + "transfer.state.RELAY_ACTIVE": "中继中", + "transfer.state.DONE": "已完成", + "transfer.state.FAILED": "失败", + "transfer.state.CANCELLED": "已取消", + "transfer.mode.p2p": "P2P", + "transfer.mode.relay": "中继", + "transfer.phase.initializing": "正在初始化…", + "transfer.phase.waiting_accept": "等待对端接受…", + "transfer.phase.ice_gathering": "收集网络候选…", + "transfer.phase.ice_checking": "建立直连通道…", + "transfer.phase.ice_connected": "通道已连接", + "transfer.phase.dc_open": "信道已打开", + "transfer.phase.transferring": "传输中", + "transfer.phase.completing": "收尾中…", + "transfer.phase.fallback_pending": "切换到中继路径…", + "transfer.phase.relay_uploading": "中继上传中", + "transfer.phase.relay_downloading": "中继下载中", + "transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)", + "transfer.bytesProgress": "{{sent}} / {{total}}", + "transfer.bytesRate": "{{rate}}/秒", + "transfer.debug.toggle": "调试信息", + "transfer.debug.iceGathering": "候选收集", + "transfer.debug.iceConnection": "连接状态", + "transfer.debug.candidatesLocal": "本端候选", + "transfer.debug.candidatesRemote": "对端候选", + "transfer.debug.selectedPair": "选中候选对", + "transfer.debug.noPair": "尚未选定候选对", + "transfer.debug.candidatesLine": "host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}", + "transfer.debug.dataChannel": "数据信道", + "transfer.debug.dcLine": "{{state}} · 待发送 {{buffered}}", + "time.justNow": "刚刚", + "time.secondsAgo": "{{n}} 秒前", + "time.minutesAgo": "{{n}} 分钟前", + "time.hoursAgo": "{{n}} 小时前", + "common.dismiss": "移除", + "common.cancel": "取消", + "common.confirm": "确认", + "errors.messageEmpty": "消息为空", + "errors.messageOverflow": "消息超过4 KB;请改用文件传输", + "errors.noReceiver": "未选择接收方", + "errors.devTokenMissing": "Dev模式:未在.env.local设置VITE_CDROP_DEV_TOKEN", + "errors.noAccessToken": "无访问令牌:用户必须先登录", + "errors.clipboardUnavailable": "浏览器不支持剪贴板API(需HTTPS与权限)", + "errors.clipboardOverflow": "内容过大,超过 {{max}} 字节上限", + "errors.clipboardWriteFailed": "写入本机剪贴板失败:{{message}}", + "ios.tab.transfer": "传输", + "ios.tab.devices": "设备", + "ios.tab.settings": "设置", + "ios.login.generating": "正在生成二维码…", + "ios.login.waiting": "等待扫码批准", + "ios.login.guide": "用另一台已登录的设备扫码批准", + "ios.login.expired": "二维码已失效,请重试", + "ios.login.failed": "登录失败,请重试", + "ios.login.refresh": "刷新二维码", + "ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”", + "ios.send.pickDevice": "选择接收设备", + "ios.transfer.sending": "发送中", + "ios.settings.engine": "引擎", + "ios.settings.status": "状态", + "ios.settings.device": "设备", + "ios.settings.clipboard": "剪贴板", + "ios.settings.clipboardNote": "上传把本机剪贴板推到其他设备;拉取写入最新云剪贴板。", + "ios.clipboard.upload": "上传剪贴板", + "ios.clipboard.pull": "拉取剪贴板", + "ios.clipboard.uploading": "正在上传…", + "ios.clipboard.pulling": "正在拉取…", + "ios.clipboard.uploaded": "已上传到云剪贴板", + "ios.clipboard.pulled": "已写入本机剪贴板", + "ios.clipboard.empty": "剪贴板为空", + "ios.clipboard.failed": "剪贴板同步失败", + "ios.devices.remove": "移除设备", + "ios.devices.thisDevice": "本机", + "ios.devices.revoked": "已移除设备", + "ios.devices.revokeFailed": "移除失败", + "ios.devices.revokeStepUp": "需在网页端重新验证后才能移除", + "ios.engine.disconnected": "未连接", + "ios.engine.ready": "已就绪", + "ios.transfer.empty": "暂无传输", + "ios.transfer.incoming": "接收", + "ios.transfer.outgoing": "发送", + "ios.send.noDevices": "没有在线设备,无法发送", + "ios.devices.empty": "暂无设备", + "ios.detail.title": "传输详情", + "ios.detail.direction": "方向", + "ios.detail.peer": "对端", + "ios.detail.state": "状态", + "ios.detail.phase": "阶段", + "ios.detail.channel": "通道", + "ios.detail.size": "大小", + "ios.detail.progress": "进度", + "ios.detail.speed": "速度", + "ios.settings.account": "账户", + "ios.settings.user": "用户", + "ios.settings.logout": "退出登录", + "ios.settings.deviceName": "设备名称", + "ios.settings.deviceNameNote": "改名将在下次登录后生效", + "ios.settings.deviceCount": "已知设备", + "ios.settings.signaling": "信令连接", + "ios.settings.presenceEvents": "在线事件", + "ios.settings.reconnecting": "重连中", + "ios.settings.lastLog": "最近日志", + "ios.tab.files": "文件", + "ios.files.title": "收到的文件", + "ios.files.empty": "还没有收到文件", + "ios.files.share": "分享", + "ios.files.done": "完成", + "ios.detail.openFile": "打开文件" +} diff --git a/ios/CDrop/Sources/Resources/i18n/zh-TW.json b/ios/CDrop/Sources/Resources/i18n/zh-TW.json new file mode 100644 index 0000000..219d5c6 --- /dev/null +++ b/ios/CDrop/Sources/Resources/i18n/zh-TW.json @@ -0,0 +1,358 @@ +{ + "app.brand": "Commilitia Drop", + "deviceType.browser": "瀏覽器", + "deviceType.macos": "macOS 用戶端", + "deviceType.windows": "Windows 用戶端", + "deviceType.linux": "Linux 用戶端", + "deviceType.ios": "iOS 用戶端", + "deviceType.shortcut": "捷徑", + "app.connected": "已連線", + "app.connecting": "正在連線…", + "app.disconnectedTitle": "正在重新連線", + "app.disconnectedHint": "與伺服器的連線已中斷,正在自動重新連線,無需重新整理頁面。", + "auth.sessionLost.title": "登入已失效", + "auth.sessionLost.body": "此裝置的登入憑證已被撤銷或逾期,請重新登入。", + "nav.accountMenu": "帳號選單", + "nav.thisDeviceLabel": "本機:", + "nav.settings": "設定", + "nav.signOut": "登出", + "nav.backToHome": "← 首頁", + "nav.language": "語言 / Language", + "nav.theme": "主題 / Theme", + "nav.theme.light": "淺色", + "nav.theme.dark": "深色", + "nav.theme.system": "跟隨系統", + "nav.themeToggle": "切換主題", + "home.greeting": "你好,{{name}}", + "home.deviceList.title": "裝置", + "home.deviceList.onlineSuffix": "({{count}} 部線上)", + "home.deviceList.showOffline": "顯示離線裝置({{count}})", + "home.deviceList.hideOffline": "隱藏離線裝置", + "home.deviceList.empty": "尚無其他裝置。在另一個分頁或裝置上以同一帳號登入,並設定一個不同的裝置名稱(設定 → 裝置名稱),即可在此處看到它。", + "home.tabs.file": "檔案", + "home.tabs.message": "訊息", + "home.tabs.clipboard": "剪貼簿", + "home.file.dropzone": "拖曳檔案到此處或點選選擇", + "home.file.selectedSize": "{{name}}({{size}})", + "home.file.rejected": "檔案已拒絕", + "home.cta.selectDeviceAndFile": "請選擇裝置與檔案", + "home.cta.selectFile": "請選擇檔案", + "home.cta.selectDevice": "請選擇裝置", + "home.cta.sendTo": "傳送到 {{name}}", + "home.message.placeholder": "傳送訊息給 {{name}}…", + "home.message.placeholderEmpty": "請先選擇裝置", + "home.message.hint": "Enter傳送,Shift + Enter換行,最長4 KB。", + "home.message.title": "訊息", + "home.message.countSuffix": "({{count}})", + "home.message.clearAll": "全部清除", + "home.transfer.active": "進行中", + "home.transfer.history": "最近的傳輸", + "home.clipboard.cloudTitle": "雲端最新", + "home.clipboard.cloudFrom": "來自 {{name}} · {{time}}", + "home.clipboard.cloudFromSelf": "來自本機 · {{time}}", + "home.clipboard.cloudEmpty": "尚未同步任何內容。", + "home.clipboard.copyToLocal": "複製到本機剪貼簿", + "home.clipboard.copyToLocalSuccess": "已複製到本機剪貼簿", + "home.clipboard.uploadTitle": "同步本機剪貼簿", + "home.clipboard.uploadHint": "僅純文字,最大64 KB;樣式(粗體 / 顏色等)會被剝除。", + "home.clipboard.uploadButton": "上傳本機剪貼簿", + "home.clipboard.uploading": "上傳中…", + "home.clipboard.uploadEmpty": "本機剪貼簿為空", + "home.clipboard.refresh": "重新整理", + "home.clipboard.clear": "清空雲端", + "home.clipboard.clearConfirm": "確認清空雲端剪貼簿?所有裝置將看到空內容。", + "home.clipboard.clearSuccess": "已清空雲端剪貼簿", + "home.clipboard.hidden": "內容已隱藏 · 點擊「顯示」查看", + "home.clipboard.reveal": "顯示", + "home.clipboard.hide": "隱藏", + "setup.title": "為本機命名", + "setup.help": "此名稱會顯示給你的其他裝置。建議避免空格,長度不超過 32 個字元。", + "setup.field": "裝置名稱", + "setup.save": "儲存並繼續", + "login.title": "登入", + "login.dev.help": "Dev模式:隨意填寫一個使用者識別碼。同識別碼的兩個分頁將模擬同一使用者的兩部裝置。", + "login.dev.field": "Dev使用者識別碼", + "login.dev.continue": "繼續", + "login.prod.help": "透過Casdoor登入。", + "login.prod.button": "透過Casdoor登入", + "oauth.failed": "登入失敗", + "oauth.signingIn": "正在登入…", + "oauth.missingParams": "缺少code或state查詢參數", + "qr.entry.fromLogin": "掃碼登入此裝置", + "qr.entry.scanNewDevice": "掃碼登入新裝置", + "qr.scan.title": "掃碼登入新裝置", + "qr.scan.subtitle": "把另一部裝置顯示的 QR 碼對準取景框。批准全程留在這部已登入裝置上。", + "qr.scan.starting": "正在啟動相機…", + "qr.scan.aiming": "把 QR 碼對準取景框", + "qr.scan.foreign": "這不是本站的登入 QR 碼。請對準新裝置上「掃碼登入此裝置」產生的碼。", + "qr.scan.noCamera": "這部裝置沒有可用的相機。請改用有相機的手機或平板掃碼。", + "qr.scan.denied": "相機權限被拒絕。請在瀏覽器網站設定裡允許使用相機,再重新開啟此頁。", + "qr.scan.error": "無法開啟相機。請重試,或改用有相機的裝置掃碼。", + "qr.scan.howto1": "在新裝置上開啟「掃碼登入此裝置」,產生一張 QR 碼。", + "qr.scan.howto2": "把那張 QR 碼對準上方取景框,辨識後會自動進入批准。", + "qr.scan.back": "返回", + "qr.show.title": "掃碼登入此裝置", + "qr.show.subtitle": "先為這部裝置命名,再用已登入的手機掃碼批准它接入你的帳號。", + "qr.show.nameHint": "此名稱會顯示給你的其他裝置,僅可使用 ASCII 字元。", + "qr.show.generate": "產生 QR 碼", + "qr.show.scanTitle": "用手機掃碼批准", + "qr.show.howto1": "在另一部已登入的裝置上開啟相機或 cdrop。", + "qr.show.howto2": "掃描上方 QR 碼,依提示批准此裝置。", + "qr.show.waiting": "等待批准…", + "qr.show.approved": "已批准,正在進入…", + "qr.show.expired": "QR 碼已逾時。重新產生一張再掃。", + "qr.show.denied": "這次接入被拒絕。如確為你本人操作,重新產生 QR 碼再試。", + "qr.show.regenerate": "重新產生 QR 碼", + "qr.show.back": "返回首頁", + "qr.show.backToName": "返回上一步", + "qr.approve.title": "批准新裝置", + "qr.approve.subtitle": "確認讓這部裝置登入你的帳號。請核對裝置與來源無誤。", + "qr.approve.loading": "正在讀取裝置資訊…", + "qr.approve.badLink": "這個批准連結不完整。請在新裝置上重新產生 QR 碼再掃。", + "qr.approve.signInFirst": "先登入你的帳號,再批准這部裝置接入。", + "qr.approve.trustLabel": "信任時長", + "qr.approve.trust.title": "信任此裝置(7 天)", + "qr.approve.trust.hint": "7 天內免再次批准,每次使用自動續期。", + "qr.approve.once.title": "僅此一次(1 小時)", + "qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。", + "qr.approve.scopeFull": "這部裝置將完整登入你的帳號、7 天內有效:可正常收發檔案與訊息,並能管理帳號與裝置。", + "qr.approve.scopeGuest": "以受限訪客身分登入——可收發檔案與訊息,但不能變更帳號、不能批准其他裝置;1 小時後失效。", + "qr.approve.nativeFull": "這是一台原生應用裝置,將以完整權限登入(信任此裝置、7 天內有效)。原生應用不支援受限訪客。", + "qr.approve.trustContinue": "信任並繼續", + "qr.approve.approve": "批准登入", + "qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。", + "qr.approve.stepUp.button": "驗證並批准", + "qr.approve.stepUp.rejected": "身分驗證未通過或已逾時。請再驗證一次,確認是你本人後即可批准。", + "qr.approve.deny": "拒絕", + "qr.approve.doneApprovedTitle": "已批准", + "qr.approve.doneApproved": "這部裝置已接入你的帳號,現在即可在它上面收發檔案。", + "qr.approve.doneDeniedTitle": "已拒絕", + "qr.approve.doneDenied": "這次接入已被拒絕,那部裝置不會登入你的帳號。", + "qr.approve.expiredTitle": "QR 碼已逾時", + "qr.approve.expired": "這張 QR 碼已失效。請在新裝置上重新產生再掃。", + "qr.approve.errorTitle": "出錯了", + "qr.approve.error": "操作沒能完成。請重試,或在新裝置上重新產生 QR 碼。", + "qr.approve.backHome": "返回首頁", + "settings.title": "設定", + "settings.currentDevice.title": "目前裝置", + "settings.deviceName.field": "裝置名稱", + "settings.deviceName.save": "儲存", + "settings.deviceName.unchanged": "新名稱與目前一致", + "settings.deviceName.empty": "名稱不能為空", + "settings.deviceName.asciiOnly": "裝置名稱只能使用 ASCII 字元(英文字母、數字、符號)。", + "settings.deviceName.success": "裝置名稱已更新", + "settings.unregister.current": "解除註冊本裝置", + "settings.unregister.currentHint": "將登出並移除本裝置的註冊。", + "settings.unregister.currentConfirm": "解除註冊本裝置會移除其註冊並登出,確認繼續?", + "settings.error.delete": "操作失敗:{{message}}", + "settings.guest.badge": "受限訪客", + "settings.guest.title": "這部裝置是受限訪客", + "settings.guest.body": "本裝置以受限訪客身分登入:可正常收發檔案、訊息與剪貼簿,但不能管理帳號、移除裝置或授權新裝置。需要完整權限,請在你的主裝置上重新登入。", + "settings.sessions.title": "登入工作階段", + "settings.sessions.hint": "這裡列出目前登入此帳號的所有裝置,含瀏覽器、桌面與行動用戶端;線上的排在上方。登出某台裝置會立即結束它的登入,需在該裝置上重新登入才能繼續使用。", + "settings.sessions.guestNote": "受限訪客無法管理登入工作階段;如需管理,請在你的主裝置上完整登入。", + "settings.sessions.empty": "尚無其他登入工作階段。", + "settings.sessions.loadError": "登入工作階段載入失敗。", + "settings.sessions.retry": "重試", + "settings.sessions.current": "本機", + "settings.sessions.scopeFull": "完整", + "settings.sessions.scopeGuest": "受限訪客", + "settings.sessions.kind.oidc": "帳號登入", + "settings.sessions.kind.self": "本機帳號", + "settings.sessions.kind.guest": "掃碼訪客", + "settings.sessions.lastUsed": "最近活躍 {{time}}", + "settings.sessions.neverUsed": "尚未活躍", + "settings.sessions.signOut": "登出", + "settings.sessions.signingOut": "正在登出…", + "settings.sessions.signOutCurrent": "登出本機", + "settings.sessions.confirmOther": "確認登出裝置「{{name}}」?該工作階段將立即失效,需重新登入。", + "settings.sessions.confirmCurrent": "登出本機等同於登出帳號,確認繼續?", + "settings.sessions.revoked": "已登出該裝置", + "settings.sessions.stepUpRejected": "身分驗證未通過或已過期,請再試一次。", + "settings.online": "線上", + "settings.offline": "離線", + "settings.lastSeen": "上次活躍 {{time}}", + "settings.push.title": "推播通知", + "settings.push.hint": "頁面關閉時,新檔案、訊息與傳輸結果會以系統通知提醒;頁面開啟時仍走應用內提示。", + "settings.push.enable": "啟用推播", + "settings.push.enabled": "已啟用", + "settings.push.disable": "關閉推播", + "settings.push.deniedHint": "通知權限已被瀏覽器拒絕,請在瀏覽器的網站設定中允許後重試。", + "settings.push.denied": "通知權限被拒絕", + "settings.push.enableOk": "已啟用推播通知", + "settings.push.disableOk": "已關閉推播通知", + "settings.push.failed": "啟用推播失敗,請重試", + "notify.incoming.title": "收到檔案", + "notify.incoming.body": "{{sender}} 傳來 {{file}}", + "notify.transfer.doneTitle": "傳輸完成", + "notify.transfer.failedTitle": "傳輸失敗", + "settings.account.title": "帳號", + "settings.account.signOut": "登出", + "settings.desktop.title": "桌面", + "settings.desktop.clipboardSync": "剪貼簿自動同步", + "settings.desktop.clipboardSyncHint": "複製後即上傳雲端,並接收其他裝置的剪貼簿更新。", + "settings.desktop.launchAtLogin": "開機自動啟動", + "settings.desktop.launchAtLoginHint": "登入系統後自動在背景啟動 cdrop(常駐選單列)。", + "settings.desktop.downloadDir": "下載資料夾", + "settings.desktop.downloadDirHint": "接收到的檔案會儲存至此資料夾;留空則使用系統下載資料夾。", + "settings.desktop.downloadDirChoose": "變更…", + "settings.desktop.downloadDirReset": "還原預設", + "settings.desktop.downloadDirPicker": "選擇下載資料夾", + "settings.desktop.ttlNote": "基於安全考量,雲端剪貼簿內容會在 5 分鐘後自動清除。", + "settings.shortcut.title": "iOS 捷徑", + "settings.shortcut.intro": "為 iOS「捷徑」簽發長效、僅限剪貼簿、可隨時撤銷的專用權杖。即使權杖外洩也只能讀寫剪貼簿、無法觸及其他資料。", + "settings.shortcut.labelField": "備註名稱", + "settings.shortcut.labelPlaceholder": "例如:我的 iPhone", + "settings.shortcut.issue": "簽發", + "settings.shortcut.empty": "尚未簽發任何權杖。", + "settings.shortcut.loadError": "權杖清單載入失敗。", + "settings.shortcut.retry": "重試", + "settings.shortcut.revoke": "撤銷", + "settings.shortcut.revoking": "正在撤銷…", + "settings.shortcut.revokeConfirm": "撤銷後使用此權杖的捷徑將立即失效,確認繼續?", + "settings.shortcut.revoked": "已撤銷", + "settings.shortcut.revokedToast": "權杖已撤銷", + "settings.shortcut.expired": "已過期", + "settings.shortcut.expiresAt": "到期 {{date}}", + "settings.shortcut.lastUsed": "上次使用 {{time}}", + "settings.shortcut.neverUsed": "尚未使用", + "settings.shortcut.unavailable": "伺服器未啟用捷徑權杖。", + "settings.shortcut.tooMany": "使用中的權杖已達上限,請先撤銷舊權杖。", + "settings.shortcut.issueError": "操作失敗:{{message}}", + "settings.shortcut.issuedTitle": "權杖已簽發", + "settings.shortcut.onceWarning": "權杖僅顯示這一次,請立即複製保存;關閉後無法再次檢視。", + "settings.shortcut.tokenLabel": "權杖(Bearer)", + "settings.shortcut.copy": "複製", + "settings.shortcut.copyFailed": "複製失敗,請手動選取複製", + "settings.shortcut.copied": "已複製", + "settings.shortcut.baseLabel": "伺服器位址", + "settings.shortcut.guideTitle": "在 iOS 上手動建立(手動同步)", + "settings.shortcut.guideIntro": "在 iPhone 的「捷徑」App 中新增兩個捷徑,首次執行時填入上方的伺服器位址與權杖:", + "settings.shortcut.guideDeviceName": "兩個捷徑都加標頭 X-Device-Name: 你的裝置名稱(僅 ASCII),讓它在裝置清單中統一顯示。", + "settings.shortcut.guidePullTitle": "拉取(雲端 → 本機剪貼簿)", + "settings.shortcut.guidePull1": "「取得 URL 內容」:網址設為 你的伺服器位址 加 /api/clipboard,方法 GET,標頭加 Authorization: Bearer 你的權杖。", + "settings.shortcut.guidePull2": "「取得字典值」:取鍵 content。", + "settings.shortcut.guidePull3": "內容非空時用「複製到剪貼簿」寫入,可再加「顯示通知」提示已拉取。", + "settings.shortcut.guidePushTitle": "上傳(本機剪貼簿 → 雲端)", + "settings.shortcut.guidePush1": "「取得剪貼簿」;若為空則停止。", + "settings.shortcut.guidePush2": "「取得 URL 內容」:網址同上,方法 PUT,標頭加 Authorization: Bearer 你的權杖 與 Content-Type: application/json,請求內容選 JSON:{\"content\": 剪貼簿, \"content_type\": \"text/plain\"}。", + "settings.shortcut.guidePush3": "可加「顯示通知」提示已上傳。", + "settings.shortcut.guideToleranceNote": "網路不穩時單次執行可能失敗,重試即可;自動輪詢版會在背景靜默重試(下一階段提供)。", + "settings.shortcut.done": "完成", + "transfer.savedTo": "已儲存至 {{path}}", + "transfer.saveFailed": "儲存檔案失敗:{{error}}", + "transfer.action.cancel": "取消", + "transfer.action.delete": "刪除", + "transfer.action.relayNow": "立即中繼", + "transfer.action.details": "詳情", + "transfer.state.PENDING": "等待中", + "transfer.state.ACCEPTED": "已接受", + "transfer.state.P2P_ACTIVE": "傳輸中", + "transfer.state.RELAY_ACTIVE": "中繼中", + "transfer.state.DONE": "已完成", + "transfer.state.FAILED": "失敗", + "transfer.state.CANCELLED": "已取消", + "transfer.mode.p2p": "P2P", + "transfer.mode.relay": "中繼", + "transfer.phase.initializing": "正在初始化…", + "transfer.phase.waiting_accept": "等待對端接受…", + "transfer.phase.ice_gathering": "收集網路候選…", + "transfer.phase.ice_checking": "建立直連通道…", + "transfer.phase.ice_connected": "通道已連線", + "transfer.phase.dc_open": "通道已開啟", + "transfer.phase.transferring": "傳輸中", + "transfer.phase.completing": "收尾中…", + "transfer.phase.fallback_pending": "切換到中繼路徑…", + "transfer.phase.relay_uploading": "中繼上傳中", + "transfer.phase.relay_downloading": "中繼下載中", + "transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)", + "transfer.bytesProgress": "{{sent}} / {{total}}", + "transfer.bytesRate": "{{rate}}/秒", + "transfer.debug.toggle": "偵錯資訊", + "transfer.debug.iceGathering": "候選收集", + "transfer.debug.iceConnection": "連線狀態", + "transfer.debug.candidatesLocal": "本端候選", + "transfer.debug.candidatesRemote": "對端候選", + "transfer.debug.selectedPair": "選中候選對", + "transfer.debug.noPair": "尚未選定候選對", + "transfer.debug.candidatesLine": "host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}", + "transfer.debug.dataChannel": "資料通道", + "transfer.debug.dcLine": "{{state}} · 待傳送 {{buffered}}", + "time.justNow": "剛剛", + "time.secondsAgo": "{{n}} 秒前", + "time.minutesAgo": "{{n}} 分鐘前", + "time.hoursAgo": "{{n}} 小時前", + "common.dismiss": "移除", + "common.cancel": "取消", + "common.confirm": "確認", + "errors.messageEmpty": "訊息為空", + "errors.messageOverflow": "訊息超過4 KB;請改用檔案傳輸", + "errors.noReceiver": "未選擇接收方", + "errors.devTokenMissing": "Dev模式:未在.env.local設定VITE_CDROP_DEV_TOKEN", + "errors.noAccessToken": "無存取權杖:使用者必須先登入", + "errors.clipboardUnavailable": "瀏覽器不支援剪貼簿API(需HTTPS與權限)", + "errors.clipboardOverflow": "內容過大,超過 {{max}} 位元組上限", + "errors.clipboardWriteFailed": "寫入本機剪貼簿失敗:{{message}}", + "ios.tab.transfer": "傳輸", + "ios.tab.devices": "裝置", + "ios.tab.settings": "設定", + "ios.login.generating": "正在產生 QR 碼…", + "ios.login.waiting": "等待掃碼核准", + "ios.login.guide": "用另一台已登入的裝置掃碼核准", + "ios.login.expired": "QR 碼已失效,請重試", + "ios.login.failed": "登入失敗,請重試", + "ios.login.refresh": "重新整理 QR 碼", + "ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」", + "ios.send.pickDevice": "選擇接收裝置", + "ios.transfer.sending": "傳送中", + "ios.settings.engine": "引擎", + "ios.settings.status": "狀態", + "ios.settings.device": "裝置", + "ios.settings.clipboard": "剪貼簿", + "ios.settings.clipboardNote": "上傳把本機剪貼簿推到其他裝置;拉取寫入最新雲端剪貼簿。", + "ios.clipboard.upload": "上傳剪貼簿", + "ios.clipboard.pull": "拉取剪貼簿", + "ios.clipboard.uploading": "正在上傳…", + "ios.clipboard.pulling": "正在拉取…", + "ios.clipboard.uploaded": "已上傳到雲端剪貼簿", + "ios.clipboard.pulled": "已寫入本機剪貼簿", + "ios.clipboard.empty": "剪貼簿是空的", + "ios.clipboard.failed": "剪貼簿同步失敗", + "ios.devices.remove": "移除裝置", + "ios.devices.thisDevice": "本機", + "ios.devices.revoked": "已移除裝置", + "ios.devices.revokeFailed": "移除失敗", + "ios.devices.revokeStepUp": "需在網頁端重新驗證後才能移除", + "ios.engine.disconnected": "未連線", + "ios.engine.ready": "已就緒", + "ios.transfer.empty": "尚無傳輸", + "ios.transfer.incoming": "接收", + "ios.transfer.outgoing": "傳送", + "ios.send.noDevices": "沒有上線裝置,無法傳送", + "ios.devices.empty": "尚無裝置", + "ios.detail.title": "傳輸詳情", + "ios.detail.direction": "方向", + "ios.detail.peer": "對端", + "ios.detail.state": "狀態", + "ios.detail.phase": "階段", + "ios.detail.channel": "通道", + "ios.detail.size": "大小", + "ios.detail.progress": "進度", + "ios.detail.speed": "速度", + "ios.settings.account": "帳戶", + "ios.settings.user": "使用者", + "ios.settings.logout": "登出", + "ios.settings.deviceName": "裝置名稱", + "ios.settings.deviceNameNote": "改名將在下次登入後生效", + "ios.settings.deviceCount": "已知裝置", + "ios.settings.signaling": "信令連線", + "ios.settings.presenceEvents": "上線事件", + "ios.settings.reconnecting": "重新連線中", + "ios.settings.lastLog": "最近日誌", + "ios.tab.files": "檔案", + "ios.files.title": "收到的檔案", + "ios.files.empty": "尚未收到檔案", + "ios.files.share": "分享", + "ios.files.done": "完成", + "ios.detail.openFile": "開啟檔案" +} diff --git a/ios/CDrop/Sources/RootView.swift b/ios/CDrop/Sources/RootView.swift new file mode 100644 index 0000000..6cf1bd4 --- /dev/null +++ b/ios/CDrop/Sources/RootView.swift @@ -0,0 +1,685 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +// 液态玻璃原生壳:底部标签栏 + 导航栈(iOS 26 SDK 自动玻璃化)。离屏无头引擎 WebView 挂在 +// 背景里保持存活、零尺寸不可见。内容卡片属内容层不加玻璃;悬浮主操作用玻璃强调按钮(功能 +// 层)。见 ios/PLAN.md §8.1。用户可见文案全走 i18n(t(),源=web catalog)。设备 / 传输 +// 列表由引擎经桥推来的真实 presence / 传输态驱动(EngineController,arch A)。 +struct RootView: View +{ + @Environment(EngineController.self) private var engine + // 初始标签可经 CDROP_TAB 环境变量指定(本机截图测试用:直接进设置看引擎状态)。 + @State private var selection = ProcessInfo.processInfo.environment["CDROP_TAB"] ?? "transfer" + + var body: some View + { + TabView(selection: $selection) + { + Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer") + { + NavigationStack + { + TransferListView() + } + } + Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices") + { + NavigationStack + { + DeviceListView() + } + } + Tab(t("ios.tab.files"), systemImage: "folder", value: "files") + { + NavigationStack + { + ReceivedFilesView() + } + } + Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings") + { + NavigationStack + { + SettingsView() + } + } + } + .tabBarMinimizeBehavior(.onScrollDown) + .tint(.cdropAccent) + .background + { + EngineWebView(controller: engine) + .frame(width: 0, height: 0) + .opacity(0) + .allowsHitTesting(false) + } + } +} + +struct TransferListView: View +{ + @Environment(EngineController.self) private var engine + @State private var showImporter = false + @State private var pickedURL: URL? + @State private var showDevicePicker = false + @State private var showNoDevices = false + + // 可发送目标:在线、且非本机(共用 EngineController.sendableDevices)。 + private var sendableDevices: [DeviceItem] + { + engine.sendableDevices() + } + + var body: some View + { + ScrollView + { + if engine.transfers.isEmpty && engine.history.isEmpty + { + ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray") + .padding(.top, 80) + } + else + { + VStack(spacing: 16) + { + if !engine.transfers.isEmpty + { + sectionHeader(t("home.transfer.active")) + ForEach(engine.transfers) + { item in + transferLink(item) + } + } + if !engine.history.isEmpty + { + sectionHeader(t("home.transfer.history")) + ForEach(engine.history) + { item in + transferLink(item) + } + } + } + .padding() + } + } + .navigationTitle(t("ios.tab.transfer")) + .overlay(alignment: .bottomTrailing) + { + Button { showImporter = true } + label: + { + Image(systemName: "paperplane.fill") + .font(.title2) + .padding(18) + } + .buttonStyle(.glassProminent) + .padding(24) + } + .fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ]) + { result in + if case .success(let url) = result + { + pickedURL = url + if sendableDevices.isEmpty { showNoDevices = true } + else { showDevicePicker = true } + } + } + .confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible) + { + ForEach(sendableDevices) + { dev in + Button(dev.name) { startSend(to: dev.name) } + } + Button(t("common.cancel"), role: .cancel) { } + } + .alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { } + } + + private func sectionHeader(_ title: String) -> some View + { + HStack + { + Text(title) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + } + + private func transferLink(_ item: TransferItem) -> some View + { + NavigationLink + { + TransferDetailView(sessionId: item.sessionId) + } + label: + { + TransferCardView(item: item) + } + .buttonStyle(.plain) + } + + private func startSend(to device: String) + { + guard let url = pickedURL else { return } + engine.sendFile(to: device, fileURL: url) + pickedURL = nil + } +} + +// 传输卡片:内容层,用语义材质背景而非玻璃(玻璃专属功能 / 导航层)。状态 / 阶段 / 速率 +// 文案全部复用共享 transfer.* catalog(强约束②)。 +struct TransferCardView: View +{ + let item: TransferItem + + var body: some View + { + VStack(alignment: .leading, spacing: 8) + { + HStack(spacing: 6) + { + Image(systemName: item.direction == "incoming" + ? "arrow.down.circle" : "arrow.up.circle") + .foregroundStyle(.secondary) + Text(item.fileName) + .font(.headline) + .lineLimit(1) + Spacer() + } + if isActive(item) + { + ProgressView(value: progressOf(item)) + } + Text(detailLine) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + + // 卡片副行:活跃时显示阶段,否则显示终态;附文件大小,活跃且有速率时附速度。 + private var detailLine: String + { + var parts: [String] = [] + if isActive(item), let phase = item.phase + { + parts.append(t("transfer.phase.\(phase)")) + } + else + { + parts.append(t("transfer.state.\(item.state)")) + } + parts.append(formatBytes(item.fileSize)) + if isActive(item), let rate = item.bytesPerSec, rate > 0 + { + parts.append(t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ])) + } + return parts.joined(separator: " · ") + } +} + +// 传输详情(TODO #5):按 sessionId 从引擎实时态查当前记录,故进度 / 速率随传输刷新; +// 传输完成后从 transfers 移入 history,仍按 sessionId 命中。 +struct TransferDetailView: View +{ + @Environment(EngineController.self) private var engine + let sessionId: String + @State private var previewItem: PreviewItem? + + private var item: TransferItem? + { + engine.transfers.first { $0.sessionId == sessionId } + ?? engine.history.first { $0.sessionId == sessionId } + } + + var body: some View + { + Group + { + if let item + { + List + { + Section + { + LabeledContent(t("ios.detail.direction"), + value: item.direction == "incoming" + ? t("ios.transfer.incoming") : t("ios.transfer.outgoing")) + LabeledContent(t("ios.detail.peer"), value: item.peerName) + LabeledContent(t("ios.detail.state"), value: t("transfer.state.\(item.state)")) + if let phase = item.phase + { + LabeledContent(t("ios.detail.phase"), value: t("transfer.phase.\(phase)")) + } + if let mode = item.mode, !mode.isEmpty + { + LabeledContent(t("ios.detail.channel"), value: t("transfer.mode.\(mode)")) + } + } + Section + { + LabeledContent(t("ios.detail.size"), value: formatBytes(item.fileSize)) + if let sent = item.bytesTransferred + { + LabeledContent(t("ios.detail.progress"), + value: "\(formatBytes(sent)) / \(formatBytes(item.fileSize))") + } + if let rate = item.bytesPerSec, rate > 0 + { + LabeledContent(t("ios.detail.speed"), + value: t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ])) + } + ProgressView(value: progressOf(item)) + } + // 收到的文件「直达」:完成的接收传输可直接打开对应文件。 + if item.direction == "incoming", item.state == "DONE", + let url = engine.receivedFile(matching: item.fileName) + { + Section + { + Button { previewItem = PreviewItem(url: url) } + label: { Label(t("ios.detail.openFile"), systemImage: "doc") } + } + } + // ICE 连接诊断:选中候选对含 "relay" 即 P2P 实走 TURN 中继(解释慢速)。 + if item.iceConn != nil || item.iceLocal != nil + { + Section + { + if let conn = item.iceConn + { + LabeledContent(t("transfer.debug.iceConnection"), value: conn) + } + if let local = item.iceLocal, let remote = item.iceRemote + { + LabeledContent(t("transfer.debug.selectedPair"), value: "\(local) ⇄ \(remote)") + } + } + } + } + } + else + { + ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray") + } + } + .navigationTitle(item?.fileName ?? t("ios.detail.title")) + .navigationBarTitleDisplayMode(.inline) + .sheet(item: $previewItem) { item in FilePreviewSheet(item: item) } + } +} + +struct DeviceListView: View +{ + @Environment(EngineController.self) private var engine + @State private var revokeTarget: DeviceItem? + @State private var showRevoke = false + + // 全部设备:含本机(本机标「本机」、不可移除自己——登出在设置页)。本机排最前。 + private var allDevices: [DeviceItem] + { + engine.devices.sorted { a, b in isSelf(a) && !isSelf(b) } + } + + private func isSelf(_ dev: DeviceItem) -> Bool { dev.name == engine.deviceName } + + var body: some View + { + Group + { + if engine.devices.isEmpty + { + ContentUnavailableView(t("ios.devices.empty"), systemImage: "laptopcomputer.slash") + } + else + { + List + { + if !engine.deviceActionStatus.isEmpty + { + Text(engine.deviceActionStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(allDevices) + { dev in + deviceRow(dev) + .contentShape(Rectangle()) + .onTapGesture { if !isSelf(dev) { revokeTarget = dev; showRevoke = true } } + .swipeActions(edge: .trailing) + { + if !isSelf(dev) + { + Button(role: .destructive) { revokeTarget = dev; showRevoke = true } + label: { Label(t("ios.devices.remove"), systemImage: "trash") } + } + } + } + } + } + } + .navigationTitle(t("ios.tab.devices")) + .confirmationDialog(revokeTarget?.name ?? "", isPresented: $showRevoke, titleVisibility: .visible) + { + Button(t("ios.devices.remove"), role: .destructive) + { + if let target = revokeTarget { engine.revokeDevice(target.name) } + } + Button(t("common.cancel"), role: .cancel) { } + } + } + + private func deviceRow(_ dev: DeviceItem) -> some View + { + Label + { + VStack(alignment: .leading, spacing: 2) + { + HStack(spacing: 6) + { + Text(dev.name) + if isSelf(dev) + { + Text(t("ios.devices.thisDevice")) + .font(.caption2) + .foregroundStyle(Color.cdropAccent) + } + } + Text(dev.online + ? t("settings.online") + : t("settings.lastSeen", [ "time": relativeTime(dev.lastSeen) ])) + .font(.caption) + .foregroundStyle(.secondary) + } + } + icon: + { + Image(systemName: deviceSymbol(dev.type)) + .foregroundStyle(dev.online ? Color.green : Color.secondary) + } + } +} + +struct SettingsView: View +{ + @Environment(EngineController.self) private var engine + @Environment(AuthManager.self) private var auth + // 设备名编辑态:持久在 UserDefaults,提交时落库。当前会话名烤在令牌里,改名下次登录生效。 + @State private var deviceNameDraft = DeviceNameStore.value + + var body: some View + { + List + { + if let user = auth.session?.user + { + Section + { + LabeledContent(t("ios.settings.user"), value: user.name) + TextField(t("ios.settings.deviceName"), text: $deviceNameDraft) + .submitLabel(.done) + .onChange(of: deviceNameDraft) { DeviceNameStore.value = deviceNameDraft } + Button(role: .destructive) { logout() } + label: + { + Text(t("ios.settings.logout")) + } + } + header: + { + Text(t("ios.settings.account")) + } + footer: + { + Text(t("ios.settings.deviceNameNote")) + } + } + Section(t("ios.settings.engine")) + { + LabeledContent(t("ios.settings.status"), value: engine.status) + LabeledContent(t("ios.settings.device"), + value: engine.deviceName.isEmpty ? "—" : engine.deviceName) + LabeledContent(t("ios.settings.signaling"), value: signalingState) + LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)") + LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)") + if !engine.lastEngineLog.isEmpty + { + VStack(alignment: .leading, spacing: 2) + { + Text(t("ios.settings.lastLog")) + .font(.caption) + .foregroundStyle(.secondary) + Text(engine.lastEngineLog) + .font(.caption2) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + Section + { + Button { engine.uploadClipboard() } + label: { Label(t("ios.clipboard.upload"), systemImage: "arrow.up.doc.on.clipboard") } + Button { engine.pullClipboard() } + label: { Label(t("ios.clipboard.pull"), systemImage: "arrow.down.doc") } + if !engine.clipboardStatus.isEmpty + { + Text(engine.clipboardStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + } + header: + { + Text(t("ios.settings.clipboard")) + } + footer: + { + Text(t("ios.settings.clipboardNote")) + } + } + .navigationTitle(t("ios.tab.settings")) + } + + // 信令连接态:已连接 / 重连中 / 未连接,三态便于区分「在努力连但连不上」与「根本没连」。 + private var signalingState: String + { + if engine.hubConnected { return t("app.connected") } + if engine.hubReconnecting { return t("ios.settings.reconnecting") } + return t("ios.engine.disconnected") + } + + // 登出:先复位引擎(断 SSE + 丢 WebView),再清会话回登录页(清 Keychain 由 auth 负责)。 + private func logout() + { + engine.reset() + auth.logout() + } +} + +// 收到的文件(TODO #2 数据侧延伸 + 解 send 测试无源文件):列 Documents 沙盒里已落盘的 +// 接收文件,可 QuickLook 预览 / 系统分享 / 转发给在线设备 / 删除。这批文件也经 Info.plist +// 的 UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace 在 Files app「我的 iPhone」 +// 下可见。转发用已有发送链路(engine.sendFile),故收到的文件可直接再发。 +struct ReceivedFilesView: View +{ + @Environment(EngineController.self) private var engine + @State private var files: [URL] = [] + @State private var previewItem: PreviewItem? + @State private var pickedURL: URL? + @State private var showDevicePicker = false + @State private var showNoDevices = false + + var body: some View + { + Group + { + if files.isEmpty + { + ContentUnavailableView(t("ios.files.empty"), systemImage: "tray") + } + else + { + List + { + ForEach(files, id: \.self) + { url in + Button { previewItem = PreviewItem(url: url) } + label: { fileRow(url) } + .buttonStyle(.plain) + .swipeActions(edge: .leading) + { + Button { startSend(url) } + label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") } + .tint(.cdropAccent) + } + .contextMenu + { + ShareLink(item: url) + { Label(t("ios.files.share"), systemImage: "square.and.arrow.up") } + Button { startSend(url) } + label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") } + Button(role: .destructive) { delete(url) } + label: { Label(t("transfer.action.delete"), systemImage: "trash") } + } + } + .onDelete { offsets in deleteAt(offsets) } // 尾滑删除 + EditButton 批量删 + } + } + } + .navigationTitle(t("ios.files.title")) + .toolbar + { + if !files.isEmpty + { + ToolbarItem(placement: .topBarTrailing) { EditButton() } + } + } + .sheet(item: $previewItem) { item in FilePreviewSheet(item: item) } + .onAppear { reload() } + .onChange(of: engine.history.count) { reload() } + .confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible) + { + ForEach(engine.sendableDevices()) + { dev in + Button(dev.name) { send(to: dev.name) } + } + Button(t("common.cancel"), role: .cancel) { } + } + .alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { } + } + + private func fileRow(_ url: URL) -> some View + { + HStack(spacing: 10) + { + Image(systemName: "doc") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) + { + Text(url.lastPathComponent) + .lineLimit(1) + Text(fileSizeString(url)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private func reload() { files = engine.receivedFiles() } + + private func delete(_ url: URL) + { + engine.deleteReceivedFile(url) + reload() + } + + private func deleteAt(_ offsets: IndexSet) + { + for i in offsets { engine.deleteReceivedFile(files[i]) } + reload() + } + + private func startSend(_ url: URL) + { + pickedURL = url + if engine.sendableDevices().isEmpty { showNoDevices = true } + else { showDevicePicker = true } + } + + private func send(to device: String) + { + guard let url = pickedURL else { return } + engine.sendFile(to: device, fileURL: url) + pickedURL = nil + } +} + +// MARK: - 展示助手 + +// 是否仍在进行中(非终态):决定显示进度条 / 阶段还是终态标签。 +private func isActive(_ item: TransferItem) -> Bool +{ + return item.state != "DONE" && item.state != "FAILED" && item.state != "CANCELLED" +} + +private func progressOf(_ item: TransferItem) -> Double +{ + guard item.fileSize > 0, let sent = item.bytesTransferred else { return 0 } + return min(1.0, Double(sent) / Double(item.fileSize)) +} + +private func formatBytes(_ bytes: Int) -> String +{ + let f = ByteCountFormatter() + f.countStyle = .file + return f.string(fromByteCount: Int64(bytes)) +} + +private func fileSizeString(_ url: URL) -> String +{ + let size = (try? url.resourceValues(forKeys: [ .fileSizeKey ]))?.fileSize ?? 0 + return formatBytes(size) +} + +// lastSeen 是 epoch 秒(后端 devices.last_seen=now.Unix(),web formatRelative 也按秒); +// 转相对时间填进共享 settings.lastSeen 文案的 {{time}}。 +private func relativeTime(_ epochSec: Double) -> String +{ + let date = Date(timeIntervalSince1970: epochSec) + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return f.localizedString(for: date, relativeTo: Date()) +} + +// 设备类型 → SF Symbol(对齐 web mapKind 的语义)。 +private func deviceSymbol(_ type: String) -> String +{ + let s = type.lowercased() + if s.contains("mac") || s == "darwin" { return "laptopcomputer" } + if s.contains("win") { return "pc" } + if s.contains("linux") { return "terminal" } + if s.contains("ipad") { return "ipad" } + if s.contains("ios") || s.contains("iphone") { return "iphone" } + if s.contains("android") { return "candybarphone" } + if s == "shortcut" { return "bolt" } + if s == "browser" { return "globe" } + return "desktopcomputer" +} + +// 品牌主色(强约束③,与 web 主 accent 对齐):浅色 #644AC9 / 深色 #9580FF。后续接 Asset +// Catalog Color Set 作单一真源;此处先以动态 UIColor 落地。 +extension Color +{ + static let cdropAccent = Color(uiColor: UIColor + { trait in + trait.userInterfaceStyle == .dark + ? UIColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1) + : UIColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1) + }) +} diff --git a/ios/CDrop/TODO.md b/ios/CDrop/TODO.md new file mode 100644 index 0000000..db9ad73 --- /dev/null +++ b/ios/CDrop/TODO.md @@ -0,0 +1,11 @@ +# cdrop iOS 待办(择期处理) + +2026-06-24 记录:扫码登录端到端验通后用户列出,留待后续处理(“A”任务覆盖的数据侧除外)。 + +1. **登录会话显示修复**(未做):iOS 客户端在(web)“登录会话”列表中误显示为“本地账号”,应按 device_type 显示“iOS 客户端”(`deviceType.ios`)。疑似列表按会话 kind(self / 扫码自签)而非设备类型贴标签——需让扫码自签会话也带 device_type 标签呈现。 +2. **设备 / session 管理非空壳**(数据侧已做,管理操作未做):列表数据已由“A”接真实 presence(`DeviceListView` 读 `engine.devices`)。剩余:会话管理操作(查看 / 吊销其他会话等)。 +3. **禁止临时登录**(未做):iOS app 不应允许 guest / 临时(once)登录;扫码登录应强制 full / persist(“信任此设备”)scope。当前后端 qr 默认 guest、`AuthManager` 也写死 `scope: "guest"`——需 iOS 侧走 full。 +4. **退出登录**(已做):设置页“账户”区已加登出(destructive),清 Keychain 会话 + 复位引擎 + 回登录页(`SettingsView.logout` → `auth.logout()` + `engine.reset()`)。 +5. **传输详情**(已做):`TransferDetailView` 按 sessionId 实时查引擎态,展示方向 / 对端 / 状态 / 阶段 / 通道 / 大小 / 进度 / 速度;卡片可点入。剩余可选:ICE 候选明细(`transfer.debug.*` catalog 已有,未接)。 + +> **A 已完成**:引擎 presence / 传输 → 原生 UI 数据绑定(三屏全替 demo)+ `TransferDetailView` + Keychain 会话持久化 + 登出 + i18n 插值。闭合 #4 / #5 与 #2 数据侧;prod 已部署 `engine.html`(presence 生效)。**剩余择期**:#1(device_type 标签)、#3(强制 full 登录)、#2 会话管理操作、#5 ICE 明细(可选)。全部仍未提交(按用户序:真机测完再 commit)。 diff --git a/ios/CDrop/project.yml b/ios/CDrop/project.yml new file mode 100644 index 0000000..0cff85d --- /dev/null +++ b/ios/CDrop/project.yml @@ -0,0 +1,52 @@ +name: CDrop +options: + bundleIdPrefix: net.commilitia + deploymentTarget: + iOS: "26.0" + createIntermediateGroups: true +settings: + base: + SWIFT_VERSION: "5.0" + CODE_SIGNING_ALLOWED: "NO" + CODE_SIGNING_REQUIRED: "NO" +targets: + CDrop: + type: application + platform: iOS + deploymentTarget: "26.0" + sources: + - path: Sources + info: + path: Sources/Info.plist + properties: + CFBundleDisplayName: Commilitia Drop + # 声明支持的语言,使系统控件(EditButton 的「编辑」、滑动删除的「删除」等系统字符 + # 串)跟随设备语言本地化,而非永远英文。开发区域设简体。 + CFBundleDevelopmentRegion: zh-Hans + CFBundleLocalizations: + - zh-Hans + - zh-Hant + - en + UILaunchScreen: {} + # 让接收文件落地的 Documents 目录在 Files app「我的 iPhone / Commilitia Drop」下 + # 可见、可就地打开(收到的文件有处可开、可被其他 app 取用)。 + UIFileSharingEnabled: true + LSSupportsOpeningDocumentsInPlace: true + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + NSLocalNetworkUsageDescription: "Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。" + NSBonjourServices: + - "_cdrop._tcp" + NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.cdrop + TARGETED_DEVICE_FAMILY: "1,2" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon +schemes: + CDrop: + build: + targets: + CDrop: all + run: + config: Debug diff --git a/ios/PARITY.md b/ios/PARITY.md new file mode 100644 index 0000000..82f4247 --- /dev/null +++ b/ios/PARITY.md @@ -0,0 +1,60 @@ +# cdrop web ↔ iOS 同步检查单 + +> 机制,不是清单快照。web(React)与 iOS(SwiftUI)视图层**有意分叉**(液态玻璃决定不可能像素一致)。一致性**优先靠强约束同步**(单一真源、机械消费、漂移不可能,见 `ios/PLAN.md` §5);本文件**只装强约束管不住、需人判定的残余**。 +> 关联:`ios/PLAN.md` 决策 D / §5。 + +--- + +## 何时跑(触发) + +- 任一 PR 改了用户可见 UI / 流程 / 元素 → 过下方“残余项”。 +- 改了后端 API 字段 → 过残余项 3。 +- 发布任一端前 → 全表过一遍 + 更新“漂移日志”。 + +--- + +## 残余项(强约束之外,逐项人工核对) + +### 1. 元素 / 屏 / 功能存在性对等 + +任一端新增用户可见的元素 / 屏 / 功能 → 确认各端都有对应(或登记为残余项 4 的“允许分叉”)。机制无法逼一个 SwiftUI View 自动长出,只能此处提醒。 + +### 2. 非引擎驱动的新流程 / 状态 + +不在共享传输引擎里的纯 UI 流程 / 状态(设置项、登录分支、空 / 错 / 加载态)→ 两端手对。**引擎驱动的传输行为不在此列**(强约束已保,无需手对)。 + +### 3. 后端 API 字段变更 + +后端改了请求 / 响应字段 → 提醒**两端瘦客户端都跟改**(后端是契约源,但客户端类型手维护)。 + +### 4. 允许分叉登记(明确 OK 不一致) + +- **视觉材质**:液态玻璃 vs Theme B。 +- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。 +- **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto;间距 / 圆角 / 字阶各用平台母语。 +- **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。 + +--- + +## PR 自检片段(复制进 PR 描述) + +- [ ] 新增 / 改动的用户可见元素 / 屏 / 功能,各端都有或已登记允许分叉? +- [ ] 新增非引擎流程 / 状态,两端已对齐? +- [ ] 改了后端字段?两端客户端都跟改? +- [ ] 新增文案走了 i18n 单一源(非硬编码)? + +--- + +## 漂移日志 + +记录每次“已同步”的两端基准,使漂移可见: + +| 日期 | web commit | iOS commit | 同步内容 | +|---|---|---|---| +| 2026-06-24 | 建档(iOS 未开工) | — | 初始:架构 A 定、引擎 / i18n / 品牌 / API 四强约束确立,检查单建立 | +| 2026-06-24 | 引擎入口 net/ios.ts + engine/main.ts(未提交) | — | web 侧无头引擎入口 + iOS 桥 + 接收 sink 接入 + Vite 多入口;tsc / build 双过 | +| 2026-06-24 | — | ios/CDrop/ scaffold(未提交) | iOS 原生壳:液态玻璃 TabView + 离屏引擎 WebView 宿主 + 桥 + 落盘管理器;编过 iphonesimulator26.5 | +| 2026-06-24 | — | ios/CDrop/ 登录+发送(未提交) | 扫码登录新设备侧(AuthManager + LoginView,对 prod qr/start 验通显码)+ 发送路径(cdrop-file scheme 供文件 + 文件选择器)+ 会话注入引擎 boot | +| 2026-06-24 | i18n locales + scripts/emit-i18n.mjs(未提交) | ios/CDrop i18n(未提交) | **强约束②落地**:web locales 加 `ios.*` 键 → `emit-i18n.mjs` 产出 JSON → iOS `t()` 读(loader I18n.swift)。用户可见**去代号 cdrop**(品牌用 `app.brand`=Commilitia Drop),显示名改 Commilitia Drop。**改 locale 后须重跑 `node web/scripts/emit-i18n.mjs`**(已记入脚本头) | +| 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices` 推 `presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history;`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`(TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()` 加 `{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html` → `engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 | +| 2026-06-24 | engine/main.ts:transfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署**) | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreview;project.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`(Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`(UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/s(relay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`,ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html` → `engine-BC6mZ9zP.js`(含节流 + ice 摘要) | diff --git a/ios/PLAN.md b/ios/PLAN.md index c219309..7aa491a 100644 --- a/ios/PLAN.md +++ b/ios/PLAN.md @@ -1,119 +1,248 @@ # cdrop iOS 客户端实施计划 -> WKWebView 原生壳 · 复用 `web/` bundle · APNs 推送 + 原生剪贴板 / 分享 -> 状态:**规划中,待 Apple Developer 账号**。本文是“账号到位即可开工”的准备文档——把架构、里程碑、以及哪些活现在就能干 / 哪些硬等签名讲清楚。 -> 关联:根 `README.md`、`desktop/PLAN.md`(同“原生壳复用同一份 UI”思路的先例)、`internal/push`(Web Push 服务端,APNs 通道与之并列)。记忆 `cdrop-state` / `cdrop-deploy-pointers`。 +> 原生 SwiftUI 界面(液态玻璃)· 离屏无头 WebView 复用传输引擎 · 控制中心剪贴板 + Share Extension + APNs +> 状态:**架构已定(2026-06-24 修订)**,账号无关部分即刻可开工;真机 / 推送 / 扩展实测硬等 Apple Developer 账号。 +> 关联:根 `README.md`、`desktop/PLAN.md`、`internal/push`、`ios/PARITY.md`(web↔iOS 同步检查单)。记忆 `cdrop-state` / `cdrop-deploy-pointers`。 --- -## 0. 范围、定位与关键决策 +## 0. 定位与关键决策(2026-06-24 修订) -**定位**:与桌面端同理——iOS 客户端不是“另写一套 UI”,而是“带原生外壳的同一份 UI”。UI 复用 `web/src` 的 React 应用(WKWebView 加载),价值全在原生层补浏览器在 iOS 上做不到的事。 +定位变更:iOS 客户端**不再**是“web UI 装进 WebView”,而是“**原生 SwiftUI 界面 + 复用的无头传输引擎**”。起因——新增“服从 iOS 设计语言(液态玻璃)”硬要求,与“WebView 里跑 React UI”直接冲突:液态玻璃是 iOS 26 系统级材质,只有原生 SwiftUI 的 `.glassEffect` 拿得到,网页只能 CSS 仿。故 UI 转原生,但**保留引擎复用**。 -**为什么要原生壳(而非仅靠 PWA)**:任务 2 的 Web Push 已让 iOS 16.4+ 已安装的 PWA 能收推送,所以“通知”本身不必等原生。原生壳的真正价值在三处 iOS 网页端做不到的能力: +### 决策 A(修订):架构 = 原生 SwiftUI 壳 + 离屏无头 WebView 引擎 -1. **原生剪贴板**:iOS 对网页 `navigator.clipboard` 限制极严(仅用户手势触发、无法后台监听),而 cdrop 的核心是剪贴板同步。原生 `UIPasteboard` 才能做到顺畅读写。 -2. **Share Extension**:从任意 app 的分享菜单直接“发送到 cdrop”,网页无法注册。 -3. **后台与通知**:原生 APNs + 后台执行窗口,配合通知点击深链回 app。 +取代 2026-06-18 的“WebView 复用 React UI”决策。 -**范围**:常驻 iOS 客户端,复用 web UI + 原生层(剪贴板 / 分享 / APNs / 登录态 Keychain)。 +- 所有可见 UI 用**原生 SwiftUI**(iOS 26 液态玻璃;旧版优雅回落,见 §7)。 +- 文件传输的 **P2P + relay + 会话 + 信令逻辑 = 现有 `web/src` 的 JS 引擎本体**,跑在**离屏(无 UI)WKWebView** 里,原生经桥驱动。零重写、保住同内网 P2P、行为与 web 天然同构(上轮刚修的“按已交付字节算进度”“抽干再标完成”等自动一致)。 +- **兜底方案 B**:若真机上无头 WKWebView 跑 WebRTC DataChannel 不稳,退“全原生 + relay-only 重写 + 放弃 P2P”。该验证留到真机可测时优先做(R-iOS-3)。 -**非范围(推后)**:iPad 专属布局、Apple Watch、App Clip、原生 SwiftUI 重写 UI(明确不做——UI 复用 web)、Android。 +**桥方向与桌面相反(关键,易踩坑)**: -### 决策 A:架构 = WKWebView 原生壳 + APNs(已定) +- 桌面(Wails):React UI 在 WebView 顶层,调 DOWN 到 Go 拿原生能力。 +- iOS(arch A):SwiftUI 原生在顶层,调 DOWN 到离屏 WebView 的 JS 引擎跑传输。 +- 即 iOS 的 WebView 是“**无头 worker**”,不是“UI 容器”。需要一个**独立的无头引擎入口**(不加载 React、只装传输模块 + RPC 桥),而非复用 React app 再隐藏 UI。 -用户在 2026-06-18 的选型中选定**WKWebView 原生壳 + APNs**(而非 PWA-only,也非全原生 SwiftUI)。理由:复用现有 web UI、与桌面瘦客户端架构一致、工作量中等,同时拿到原生剪贴板 / 分享 / 后台。 +### 决策 B(修订):分发 = 非 App Store,但仍守苹果开发标准 -### 决策 B:硬等 Apple Developer 账号 +- **必须付费 Apple Developer Program(ADP,$99/年)**:cdrop 四项能力——APNs、App Groups、Keychain 共享、Associated Domains——**免费 Personal Team 一律不支持**(账号策略,非 OS 封锁),且免费证书 7 天过期。故“等账号”=等**付费 ADP**。 +- **分发走 Ad Hoc**(最贴近桌面 Dropbox 旁加载的等价物):100 台 / 年上限、设备 UDID 须预先登记、profile 年度更新。自用(自己几台设备)足够;比桌面“下载即用”多一道 UDID 登记摩擦——iOS 无更省的合规途径。 + - 备选 **TestFlight**:体验最好(无需 UDID、可达万级),但**需经 Apple Beta 审核**、build 90 天过期——与“不上架”初衷部分相悖,留作扩面再议。 + - **Enterprise(ADEP $299/年)**:无设备上限,但仅限“内部员工”,对外分发违反协议、证书可被吊销——不取。 +- **不过审 ≠ 不守标准**:仍遵守 HIG、液态玻璃规范、App Intents / WidgetKit 约定、隐私(权限说明 / just-in-time 请求)、entitlements 最小化。原 R-iOS-5(Guideline 4.2 过审风险)在 Ad Hoc 下作废;若走 TestFlight 则 Beta 审核仍在。 -iOS 的真机调试、TestFlight / App Store 分发、APNs 全部要求 Apple Developer Program 成员资格 + 签名证书 + Provisioning Profile。**账号到位前**只能:写服务端 APNs 通道(账号无关的代码结构)、scaffold Xcode 工程、在模拟器跑 UI(模拟器不收 APNs)。**账号到位后**才能:真机推送、剪贴板 / 分享扩展真机验证、分发。 +### 决策 C(新):剪贴板 = 控制中心两控件,零推送 + +- 通道用**持久的 `/api/clipboard`**(REST、LWW、版本探针、作用域令牌可访问),不用 ephemeral 的 `/api/message`。 +- iOS 18 控制中心控件(`ControlWidget` + App Intent)提供“上 / 下”两件:**上** = 读 `UIPasteboard` → `PUT /api/clipboard`;**下** = `GET` → 写 `UIPasteboard`。用户主动点、**零推送**(频繁剪贴板变动推送干扰极大)。 +- 平台天花板:程序化**读**剪贴板弹一次系统横幅(写不弹);后台无法监听复制。故“后台自动同步”降级为“一键发送 / 一键拉取”。 +- 这条链**纯原生 URLSession,不依赖无头 WebView**——与文件传输引擎彻底解耦,互不拖累。 + +### 决策 D(新):视图层分叉,先吃强约束、残余进检查单 + +接受 web(React)与 iOS(SwiftUI)视图层是两套实现(液态玻璃决定不可能像素一致)。一致性按两层管,详见 §5: + +- **强约束同步**(机制保证、漂移不可能):① 传输行为靠 arch A 共享引擎;② i18n 文案单一源(iOS 读 web 那份 JSON);③ 品牌资产 + 主色单一源;④ 后端 API 即共享契约。 +- **检查单**(机制管不住、人判定的残余):元素 / 屏 / 功能存在性对等、非引擎驱动的新流程、后端字段变更提醒、允许分叉登记。 +- **明确不做**:设计令牌 codegen 管线(过度工程,且与液态玻璃系统材质 / 语义色 / SF Symbol 母语相冲)——间距 / 圆角 / 字阶归“允许分叉”,仅品牌主色 + logo 作共享源。 + +### 决策 E(新):权限策略 = 持久权限首启显式询问 + 缺失则降级或阻塞提示 + +- **持久型权限(本地网络、相机)在首次启动的引导中显式询问**:先用一屏说明用途、再触发系统弹窗——既满足“首启显式获取”,又不踩 HIG 的“无上下文裸弹窗”反模式。这类权限**一次授予长期持久**(iOS 18 有重装后状态不重置的已知 bug,需 UI 兜底)。 +- **缺失 / 拒绝时按特性二选一**:① **有兜底则降级**——本地网络→回退中继并提示“仅中继、无法同内网直连”;通知→无后台推送但前台仍可用。② **无兜底则提示“无权限”并阻塞该特性**——相机→扫码不可用,提示 + 跳系统设置;阻塞的是**该特性**、非整个 app。 +- 不拿任一权限当**整个 app** 的门槛(HIG 5.1.2)。详见 §9.2 逐权限映射。 --- -## 1. 与任务 2(Web Push)服务端模型的关系 +## 1. 后端现状(决定 iOS 能做什么,2026-06-24 扫描) -任务 2 已落地服务端推送底座,iOS 的 APNs 通道**复用同一套设计**,只换发送后端: - -| 维度 | Web Push(已建) | iOS APNs(待建) | +| 能力 | 结论 | 对 iOS 的含义 | |---|---|---| -| 凭据 | 自签 VAPID 公私钥 | Apple APNs Auth Key(`.p8` + Key ID + Team ID) | -| 订阅令牌 | endpoint + p256dh/auth | APNs device token(每安装一枚) | -| 存储 | `push_subscriptions` 表 | 同表加 `platform` 列区分,或新 `apns_tokens` 表 | -| 收件定位 | `(user_id, device_name)` | 同上——iOS 设备登记 `device_type=ios` | -| 触发门控 | `Hub.SendTo` 未投递才推 | **完全相同**——前台 WebView 在线即走 SSE,挂起 / 关闭才 APNs | -| 文案渲染 | 服务端按订阅 `locale` 渲染 | 同上,复用 `internal/push` 的 `localize` | +| 文件传输 | 纯实时(P2P / 实时 relay,relay 是内存环 64MiB、2 分钟空闲释放),**零落盘暂存** | Share Extension 不靠服务端暂存;接收方须同时在线 | +| 剪贴板 `/api/clipboard` | 持久 LWW + REST,作用域令牌可访问 | 决策 C 的纯原生链路 | +| 消息 `/api/message` | 临时、不持久、不可轮询 | 文本同步走剪贴板通道,消息仅尽力而为实时 | +| 作用域令牌 | 仅 `clipboard` 一个 scope,`rejectScoped` 挡死 `/transfer` | 发送须完整会话;Share Extension 用交接而非自带令牌 | +| 推送 | 仅 Web Push(VAPID),无 APNs | APNs 须新建(§3) | +| 原生鉴权 | IdP bearer(RS256)/ 扫码自签会话;设备隐式登记(`X-Device-Name`) | 原生 app 可正常登录持会话 | -**关键**:“页面打开用应用内提示,否则系统通知”的判定在 iOS 上=**app 是否前台**。前台时 WebView 的 SSE 在线、`Hub.SendTo` 投递成功 → 应用内呈现;app 挂起 / 退后台 → SSE 断 → `SendTo` 失败 → 服务端发 APNs。与 Web 端逻辑同构,只是“在线”的物理含义不同。 - -> ⚠️ iOS WebView 退后台后会被系统挂起,SSE 必断。所以 iOS 的后台通知**只能**靠服务端 APNs,**不能**像桌面那样靠常驻进程里的 JS(桌面 Go 进程常驻、iOS 没有等价物)。这正是必须建 APNs 通道、而非复用桌面 `notifyNativeIfBackground` 思路的原因。 +Share Extension 落法(据上):**抓文件 → 写 App Group 容器 → 深链唤起主程序**选设备发送(接收方须在线、大文件主程序稳跑、扩展不持完整令牌更安全)。 --- -## 2. 里程碑 +## 2. 桥协议(arch A 的接口契约,账号无关、可先冻结) + +原生 ↔ 无头 JS 引擎的 RPC:`WKScriptMessageHandler`(JS→Swift)+ `evaluateJavaScript`(Swift→JS),类比 Wails 的 `EventsEmit` / bound methods,但**方向相反**(原生在顶层)。 + +- **平台判定**:`isIOSShell()`(`web/src/net/ios.ts`)查 `window.webkit.messageHandlers.cdropEngine` 在场(类比 `isDesktop()` 查 `window.go.main.App`)。无头入口(`web/src/engine/main.ts`)据此装配桥。 +- **注入**:原生在加载 bundle 前经 WKUserScript 注入 `window.__CDROP_BOOT__ = { session, device_name, api_base, device_type:"ios" }`,store 在 import 时即水合(沿桌面同一注入范式);JS 永不持有长寿命密钥(refresh_token 只在原生 Keychain)。 +- **原生 → 引擎命令**(原生 `evaluateJavaScript` 调 `window.__cdropEngineEvent(name, payload)`):`sendFile { target, url, name, size, type? }`、`cancelTransfer { sessionId }`、`switchToRelay { sessionId }`、`session { access_token, user }`(续期令牌)、`shutdown`。 +- **引擎 → 原生**:① 单向通知(`postMessage({ notify, payload })`):`ready` / `transfers` / `transferDone` / `sendStarted` / `error`;② 请求 / 响应 RPC(`postMessage({ id, method, payload })`,原生回调 `window.__cdropEngineResolve(id, ok, value)`):接收落盘 `saveDownload` / `beginDownload` / `appendDownload` / `finalizeDownload` / `abortDownload`(data 为 base64,复用桌面同形)。剪贴板不走此桥(决策 C 纯原生)。 +- **复用边界**:接收侧字节桥沿用桌面已验证的 base64 分批、有界内存(iOS 上限更小,64MB/16MB 避 jetsam);**发送侧经 `url` 取回文件为唯一新实现点**——当前整文件 fetch 进内存(小 / 中文件可行),大文件按 slice 向原生拉取是真机优化点(R-iOS-4)。 +- **已落地(web 侧,账号无关、`tsc`/`vite build` 双过、对现网零影响)**:`net/ios.ts`(桥 + `isIOSShell` + 接收封装 + `notifyNative`)、`engine/main.ts`(无头入口)、`incomingSink.ts`(通用混合槽路由 iOS)、`engine.html` + `vite.config.ts` 多入口。 +- **加载源(关键)**:无头引擎**必须从安全上下文加载**——`RTCPeerConnection` 在非安全 origin(如自定义 `cdrop://` scheme,WebKit 视作 non-secure context)下可能被禁。故引擎页走 `https://`:线上 `drop.commilitia.net` 或应用内本地 https 服务的静态 bundle;**自定义 scheme 仅用于 `WKURLSchemeHandler` 给 JS 流式喂文件字节**,不可用它直载引擎页。见 R-iOS-3。 + +--- + +## 3. 服务端 APNs 通道(账号无关的结构,`.p8` 到位再落代码) + +新增 `internal/apns` 与 `internal/push` 并列,只换发送后端: + +- **库**:APNs 走 HTTP/2 + JWT(`.p8` 的 ES256 签名),可用 `github.com/sideshow/apns2`,或自手写(与现有 go-jose 一致)。 +- **配置**(koanf,`CDROP_APNS_*`,全可选,缺失即 iOS 推送惰性关闭):`APNS_KEY_PATH`、`APNS_KEY_ID`、`APNS_TEAM_ID`、`APNS_TOPIC`(bundle id)、`APNS_ENV`。 +- **存储**:`push_subscriptions` 加 `platform` 列,iOS 行存 APNs token。`SubscriptionID` 仍用 token 哈希保幂等 upsert。 +- **端点**:`POST /api/push/apns/register {device_token, locale}`(鉴权 + `X-Device-Name`,与 `/api/push/subscribe` 同组)。 +- **触发**:在 `transfer:incoming` / `transfer:done|failed` / `message` 的“未投递”分支按 `platform` 分流。文案复用 `internal/push` 的 `localize`。**注意——剪贴板不触发推送**(决策 C 走控制中心拉取)。 + +代码结构账号无关,但真发 / 真测要 `.p8`,故与 iOS 客户端同期落(账号到位后),避免悬空实现。 + +--- + +## 4. 里程碑(修订) | 里程碑 | 内容 | 账号依赖 | |---|---|---| -| **I0** 账号与证书前置 | Apple Developer Program 加入;App ID(`net.commilitia.cdrop`,与桌面 bundle id 一致或加 `.ios` 后缀);APNs Auth Key `.p8`;Provisioning Profile | **硬等账号** | -| **I1** WKWebView 壳骨架 | Xcode 工程;WKWebView 加载 `https://drop.commilitia.net`(或内嵌 `web/dist`);注入 `X-Device-Type: ios`;基础生命周期 | 模拟器可跑;真机等账号 | -| **I2** 登录态 | `ASWebAuthenticationSession` 跑 OIDC PKCE(系统级安全浏览器,复用 web client);refresh_token 存 **iOS Keychain**,JS 永不持有(与桌面 Go keyring、web HttpOnly cookie 同策略) | 模拟器可跑 | -| **I3** APNs 推送(核心) | 原生注册 APNs device token → 上报 `POST /api/push/apns/register`;**服务端 APNs 发送通道**(见 §3);通知点击深链回 app | 真机硬等账号 | -| **I4** 原生剪贴板 | `UIPasteboard` 读(用户手势 / 前台)+ 写;与云剪贴板 LWW 模型对接(复用 `/api/clipboard`);说明 iOS 后台无法监听的限制 | 模拟器部分可跑 | -| **I5** Share Extension | 独立 extension target:从任意 app 分享文件 → 唤起 cdrop 发送流程 | 真机硬等账号 | -| **I6** 打磨与分发 | 后台执行窗口;图标 / 启动屏(复用品牌资产);TestFlight → App Store | 硬等账号 | +| **I0** 账号与证书前置 | **付费 ADP($99/年,4 项能力全要)**;主 app + 各扩展 App ID(扩展 id 须主 app 前缀);开启 Push / App Groups / Keychain / Associated Domains;注册 App Group ID;APNs Auth Key `.p8`(**仅一次下载**);Distribution 证书 + Ad Hoc Profile(登记设备 UDID) | 硬等付费账号 | +| **I1** 原生骨架 + 无头引擎装配 | Xcode 工程(SwiftUI);离屏 WKWebView 载无头引擎入口;`isIOSShell` 注入桥 | 模拟器可跑 UI;引擎入口 web 侧账号无关 | +| **I2** 登录态 | `ASWebAuthenticationSession` PKCE / 扫码;refresh_token 存 Keychain;session 注入 JS | 模拟器可跑 | +| **I3** 传输(核心) | 无头引擎桥(send 自定义 scheme 流式 / receive 复用 `Begin`-`Append`-`Finalize`)+ 原生传输 UI(液态玻璃、含进度 / 速度 / 状态) | 真机硬等账号 | +| **I4** 剪贴板 | 控制中心两控件 + App Intents;纯原生 `/api/clipboard` 读写 + 版本探针 | 控件真机硬等账号;REST 链路可先写 | +| **I5** Share Extension | 抓文件 → App Group → 深链主程序选设备发送 | 真机硬等账号 | +| **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 | +| **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 | --- -## 3. 服务端 APNs 通道(账号无关的结构,可先设计) +## 5. 一致性:强约束同步 vs 检查单(决策 D 展开) -新增 `internal/push` 的兄弟实现 `internal/apns`(或 `push.Sender` 旁加一个 APNs 后端),与 Web Push 并列: +**检查单是兜底,不是默认。** 先尽量用强约束(单一真源、两端机械消费、漂移不可能),管不住的残余才进检查单(见 `ios/PARITY.md`)。 -- **库**:APNs 走 HTTP/2 + JWT(`.p8` 的 ES256 签名),可用 `github.com/sideshow/apns2` 之类,或自手写(与现有 go-jose 一致)。 -- **配置**(koanf,`CDROP_APNS_*`,全可选——缺失即 iOS 推送惰性关闭,类同 VAPID):`APNS_KEY_PATH`(`.p8`)、`APNS_KEY_ID`、`APNS_TEAM_ID`、`APNS_TOPIC`(bundle id)、`APNS_ENV`(sandbox / production)。 -- **存储**:`push_subscriptions` 加 `platform TEXT NOT NULL DEFAULT 'web'` 列 + iOS 行存 APNs token(复用 `endpoint` 字段存 token,或加专列)。`SubscriptionID` 仍用 token 的哈希保持幂等 upsert。 -- **端点**:`POST /api/push/apns/register {device_token, locale}`(鉴权 + `X-Device-Name`,与 `/api/push/subscribe` 同组)。 -- **发送**:在 `transfer:incoming` / `message` / `transfer:state(DONE|FAILED)` 的“未投递”分支里,按 `platform` 分流到 Web Push 或 APNs。文案复用 `internal/push` 的 `localize`(已支持 zh-CN / zh-TW / en-US)。 +### 强约束(机制保证) -> 这部分**代码结构账号无关**,但 APNs 发送要 `.p8` 才能真发 / 真测,故**与 iOS 客户端同期做**(账号到位后),避免落一坨无法验证的代码。设计先定在此。 +1. **传输行为 → arch A 共享 JS 引擎。** 整套状态机 / 进度 / 完成 / 错误 / 协议无法漂移,零额外成本。 +2. **i18n 文案 → 单一源。** iOS 原生侧薄 loader 读 web 那份 i18n JSON(随 bundle 或从服务端取),不写转换器、不做 `.xcstrings` 镜像。缺键即露。 +3. **品牌资产 + 主色 → 单一源。** 资产沿用 Dropbox 那套(web+desktop 已接);品牌主色 + logo 作小共享常量。 +4. **后端 API → 后端即共享契约。** 两端瘦客户端对齐同一 JSON 形状,不加 codegen(过度工程);字段变更的人工残余进检查单。 + +### 检查单(人判定的残余) + +见 `ios/PARITY.md`:① 元素 / 屏 / 功能存在性对等;② 非引擎驱动的新流程 / 状态;③ 后端 API 字段变更提醒;④ 允许分叉登记。 + +### 明确不做 + +设计令牌 codegen 管线——过度工程,且与液态玻璃系统材质 / 语义色 / SF Symbol 母语相冲。间距 / 圆角 / 字阶归“允许分叉”。 --- -## 4. 复用边界与平台注入 +## 6. 现在能做 vs 等账号(修订) -- **UI 全复用** `web/src`:iOS 壳不写业务 UI。 -- **平台判定**:web 现有 `isDesktop()` 靠 `window.runtime` + `window.go`(Wails 注入)。iOS 壳需类似注入一个标识(如 `window.__CDROP_IOS__` 或 `WKScriptMessageHandler` 桥),前端加 `isIOSShell()`,让原生剪贴板 / 分享走桥、Web Push 入口隐藏(iOS 用 APNs 不用 VAPID)。 -- **JS↔原生桥**:`WKScriptMessageHandler`(JS→Swift)+ `evaluateJavaScript`(Swift→JS),类比 Wails 的 `EventsEmit` / bound methods。需要的桥方法初稿:`requestApnsToken`、`readClipboard` / `writeClipboard`、`saveFile`、`showNotification`(前台)。 -- **登录回跳**:`ASWebAuthenticationSession` 的 callback URL scheme 注册(如 `cdrop://oauth/callback`),与 web 的 `redirect_uri` 并存(后端 `/api/auth/config` 已可按 client 下发;或复用 web client 的 loopback 思路)。 +**账号无关、即刻可做**: + +- 本计划 + `ios/PARITY.md` 同步检查单(✔ 本轮)。 +- web 侧**无头引擎入口**(✔ 已实现,账号无关):`net/ios.ts`(桥 + `isIOSShell()` + 接收封装 + `notifyNative`)、`engine/main.ts`(不加载 React 的独立入口:水合 store → `startHub` → 暴露 RPC + 订阅推事件)、`incomingSink.ts`(通用混合槽路由 iOS,内存上限 64MB/16MB 避 jetsam)、`engine.html` + `vite.config.ts` 多入口。`tsc` / `vite build` 双过、对现网 web / 桌面零影响。 +- 桥协议契约冻结(§2,✔ 已与实现对齐)。 +- i18n 单一源:现有 catalog 已是扁平 `{ key: string }` 结构(`src/i18n/locales/*.ts`),数据形态即可导出 JSON;原生侧薄 loader + 构建期 emit 待原生工程就位时同期落(避免向无消费方空导出)。 +- 剪贴板 REST 链路(`/api/clipboard` 读写 + 版本探针)的数据流设计(控件实现等账号,协议可先定)。 +- **iOS 原生 scaffold**(✔ 已实现,账号无关):`ios/CDrop/`(xcodegen `project.yml`)——液态玻璃壳(TabView + `.tabBarMinimizeBehavior` + `.buttonStyle(.glassProminent)`)+ 离屏引擎 `WKWebView` 宿主 + 桥 `EngineController`(对侧契约同 `net/ios.ts`)+ `DownloadManager`(落沙盒)。**已编过 `iphonesimulator26.5`(iOS 26 SDK,含液态玻璃)**——证 arch A Swift 侧成立。**启动 / 截图待 iOS 26 模拟器运行时(约 7GB,账号无关)下载**(`xcodebuild -downloadPlatform iOS`)。 +- **明确不做**(避免悬空 / 过度工程):设计令牌 codegen;APNs 发送代码(`.p8` 到位再落)。(注:原“Xcode 工程等账号”已作废——**模拟器构建不需签名 / Provisioning**,工程已建且 compile-verified;只有真机签名 / APNs 真发 / 分发才等付费 ADP。) + +**硬等付费 ADP 账号**:真机签名 / 跑、APNs 真发、Share Extension、控制中心控件真机、旁加载分发。 --- -## 5. 风险与未决 +## 7. 风险与未决(修订) -- **R-iOS-1 WebView 后台挂起**:app 退后台 SSE 必断 → 后台通知只能靠 APNs(已纳入 I3)。前台恢复时 WebView 需重连 SSE(web `startHub` 的重连循环已具备)。 -- **R-iOS-2 剪贴板后台限制**:iOS 不允许后台读剪贴板(隐私);只能前台 / 用户手势。同步语义要据此降级(前台进入时同步一次,而非桌面式实时监听)。iOS 14+ 还有“粘贴需用户确认”横幅。 -- **R-iOS-3 远端 vs 内嵌 bundle**:WKWebView 加载线上 `drop.commilitia.net`(部署即更新、但离线不可用)vs 内嵌 `web/dist`(随 app 版本、可离线壳)。倾向**内嵌 bundle + API 指向线上**(与桌面一致:`go:embed` 的对应物是 app bundle 里的静态文件),首屏快且可控。 -- **R-iOS-4 App ID 与 audience**:iOS client 若用独立 OAuth client_id,后端 `CDROP_OIDC_AUDIENCE` 需纳入(已支持逗号分隔多值);若复用 web client(如桌面),则无需改。倾向复用 web client(最省)。 -- **R-iOS-5 审核**:App Store 审核对“仅是网页壳”的 app 有顾虑(Guideline 4.2)。原生剪贴板 + 分享扩展 + APNs 提供了足够的原生价值,过审风险可控,但需在审核说明里强调。 +- **R-iOS-1 后台挂起 / JS 全停**:app 退后台即挂起、WKWebView JS(含 WebRTC 事件循环)停摆,DataChannel 无媒体轨后台不保活。文件传输本需前台(双方在线、用户看进度),可接受;用户主动触发的传输用 iOS 26 `BGContinuedProcessingTask` 续跑(带系统进度 UI)。剪贴板走控制中心 App Intent,不依赖常驻 WebView。 +- **R-iOS-2 剪贴板后台限制**:读弹横幅、后台不可监听 → 控制中心手动两控件已据此设计;app 内读用 `UIPasteControl`、探测用 `hasStrings`(均不弹横幅)。 +- **R-iOS-3 无头 WebRTC 真机稳定性 + 安全上下文**:arch A 最大未验证点——① `RTCPeerConnection` 须在安全 origin;② `BGContinuedProcessingTask` 能否让 WebView JS 不被挂起待验。**2026-06-24 模拟器已部分证实**:iOS 26 WKWebView 从 `http://127.0.0.1`(`isSecureContext=true`,loopback = 潜在可信源)跑 `RTCPeerConnection` + DataChannel + `createOffer` = **RTC:ok**、STUN srflx 候选正常、原生↔JS 桥往返正常 → **arch A 核心成立、不转 B**。注:安全源不止 https,loopback 亦可(可选应用内本地 https/loopback 服务内嵌 bundle,离线可用)。**余真机验**:host / 同内网直连候选 + 本地网络权限(R-iOS-6)、后台挂起续传(BGContinuedProcessingTask)。 +- **R-iOS-4 字节桥**:接收侧复用桌面 `Begin`/`Append`/`Finalize`(base64 分批、有界)已验证;发送侧自定义 scheme(`WKURLSchemeHandler`)流式喂 JS 为新实现点。 +- **R-iOS-5**(原 App Store 过审)在 Ad Hoc 旁加载下作废;若改走 TestFlight 则 Beta 审核仍在。 +- **R-iOS-6 本地网络权限(同内网 P2P 硬墙)**:WebRTC host 候选 / mDNS 触发 `NSLocalNetworkUsageDescription`(须配 `NSBonjourServices`),拒绝即只剩中继候选 → 回退 relay(cdrop 已有兜底);mDNS 失败不可区分“无服务”,UI 须降级。**不可拿它当核心功能门槛**。 +- **R-iOS-7 付费账号 + 分发摩擦**:4 项能力 + 分发全需付费 ADP;Ad Hoc 100 台 + UDID 预登记是旁加载硬约束——比桌面 Dropbox“下载即用”多一道摩擦。 --- -## 6. 现在能做 vs 等账号 +## 8. iOS 设计规范要点(HIG · 液态玻璃) -**账号到位前可做(账号无关)**: -- 本计划(✔ 本文)。 -- 服务端 APNs 通道的**设计冻结**(§3)——代码待 `.p8` 同期落,避免无法验证的悬空实现。 -- 前端 `isIOSShell()` 平台注入点的预留(可与 iOS 壳同期,改动小)。 +抓取自 Apple HIG / WWDC25(2026-06-24),只取对 cdrop 界面有约束力的重点。 -**硬等 Apple Developer 账号**:Xcode 工程真机签名、APNs 真发真测、Share Extension、TestFlight / App Store 分发。 +### 8.1 液态玻璃(材质用法) + +- **两层模型**:玻璃只用于**功能 / 导航层**(Tab bar / Nav bar / Toolbar / 浮动控件 / sheet 边框);**内容层禁用**——列表、设备卡、**传输卡属内容层,不加玻璃**。 +- 用 **iOS 26 SDK 编译,Tab bar / Nav bar / Toolbar / sheet 自动玻璃化**;同时**移除所有自定义 bar 背景 / 装饰色**(否则破坏系统玻璃)。 +- 自定义悬浮控件(如传输卡上的取消 / 暂停 pill)才手动 `.glassEffect(.regular.interactive())`,多块玻璃须放进 `GlassEffectContainer` 共享采样;主操作用 `.buttonStyle(.glassProminent)`。 +- **禁忌**:玻璃叠玻璃、滚动内容区铺玻璃、大面积内容玻璃背景、非主操作滥用 tint。 +- Tab bar 滚动收起 `.tabBarMinimizeBehavior(.onScrollDown)`;全局传输状态可置 `.tabViewBottomAccessory`(类 Apple Music 迷你条)。圆角走同心 `.rect(cornerRadius: .containerConcentric)`,sheet / popover 系统自动同心。 + +### 8.2 基础(色彩 / 字体 / 图标 / 布局) + +- **色彩**:用**语义色**(`label` / `secondaryLabel`、`systemBackground` 层级、`systemFill`、`separator`),**不硬编码 hex**——暗色 / 对比度 / vibrancy 免费。**品牌主色以 Asset Catalog 的 Color Set(含 Dark 变体)接入**(`Color("BrandPrimary")`)——这正是强约束③(§5)在 iOS 侧的落地形态。 +- **字体 / Dynamic Type**:系统 text styles(Body 17 / Headline / Title…)+ **必须支持 Dynamic Type**(正文 / 标题 / 主按钮可缩放)。与“字体归允许分叉”不矛盾——分叉的是**字体选择**,Dynamic Type 支持是**硬要求**。 +- **SF Symbols**:优先系统符号(自动与文字对齐 / 字重匹配),四种渲染模式(mono / hierarchical / palette / multicolor);品牌专属才自定义 symbol(须注解四模式以获 Dynamic Type / 无障碍)。 +- **布局**:内容 / 控件留安全区,**最小命中区 44×44pt**,对比度 **4.5:1**(正文)/ **3:1**(大字 / 非文字),不硬编码宽度。 + +### 8.3 控件 / App Intents(剪贴板两控件落地) + +- 剪贴板“上 / 下”用 **`ControlWidgetButton`**(一次性动作);**SF Symbol 必备**(锁屏 / 操作按钮只显示图标,须独立达意)。跨设备状态靠**推送 reload**(`ControlCenter.reloadControls`)、**不轮询**;控件内**不发网络请求**,经 App Group 容器读主 app 写入。 +- **App Intent**:动词+宾语命名、`title` 编译期常量本地化串、**默认后台执行**(返回 dialog / snippet、不开 app);iOS 26 **Interactive Snippets** 可在不开 app 下做“选设备”二级操作。 + +### 8.4 分享(Share Extension) + +- 扩展只做轻:内容预览 + 选设备 + 验证 → 写 **App Group 容器** + 交接主 app;**不在扩展内跑传输引擎**(内存 ~120MB,见 §9.1)。宽度系统固定不可改;完成即“已提交”退出,不等传输完成。 + +### 8.5 通知(APNs) + +- 文案具体(“文件 X 已接收(3.2MB)”);中断级别用 **`.active`**(传输完成 / 失败),**不滥用 `.timeSensitive`**;`threadIdentifier` 按“完成 / 失败 / 消息”分组;**首个有价值操作后**再 just-in-time 请求权限,按类别细分开关。**剪贴板不推送**(决策 C)。 + +### 8.6 无障碍(玻璃语境) + +- Reduce Transparency / Increase Contrast / Reduce Motion:系统对 Tab / Nav 自动回落;**自定义玻璃控件须自行提供不透明回落 + 关动效**。传输进度动画检测 `accessibilityReduceMotion` 降级为渐隐 / 变色。VoiceOver:图标按钮 `accessibilityLabel` 用动词(“发送文件”而非“箭头向上”)。 + +### 8.7 App 图标(iOS 26) + +- 用 **Icon Composer** 做分层图标(logo 前景 + 品牌色渐变背景),系统材质自动出高光 / 折射 / 阴影——源稿**不要**烘焙圆角 / 阴影 / 斜面。须验明 / 暗 / clear / tinted 各变体(暗背景下 logo 填充色可辨)。接品牌资产源(强约束③)。 --- -## 附:与桌面端的策略对照(一以贯之) +## 9. 开发禁区 / 平台约束(硬性 vs 审核 / 账号) -| 维度 | 桌面(Wails) | iOS(WKWebView 壳) | +分级:**【OS 硬】**=系统强制、绕不过;**【账号】**=付费 / 账号策略;**【审核】**=仅 App Store / TestFlight 审核约束(Ad Hoc 旁加载不触发)。 + +### 9.1 运行 / 生命周期 + +- **【OS 硬】后台一律挂起、JS 全停**:app 退后台即被挂起,WKWebView 的 JS(含 WebRTC 事件循环)停摆,DataChannel(无媒体轨)后台不保活 → 传输**必须前台发起**;用户主动触发的传输用 iOS 26 **`BGContinuedProcessingTask`** 续跑(带系统进度 UI,能否让 WebView JS 不被挂起须真机验,R-iOS-3)。 +- **【OS 硬】无后台常驻监听**:剪贴板 / 消息**不能**后台轮询监听 → 决策 C 的控制中心手动触发正据此。 +- **【OS 硬】自定义 scheme = 非安全上下文 + OPFS 单文件 ~10MB**:故引擎从 https 安全源加载(§2)、大文件**不走 OPFS**、经原生桥 `Begin`/`Append`/`Finalize` 落沙盒(接收侧已是此设计)。 +- **【OS 硬】Share Extension ~120MB jetsam**:扩展内**装不下** WKWebView + WebRTC 引擎 → 只做交接(§8.4)。 + +### 9.2 权限 / 隐私 / 网络(落实决策 E 的逐权限映射) + +- **【OS 硬】本地网络权限(持久)**:同内网 P2P 直连(WebRTC host 候选 / mDNS)触发 `NSLocalNetworkUsageDescription`,**须配 `NSBonjourServices`**;一次授予长期持久 → **首启引导显式询问**(决策 E)。缺失静默失败、拒绝则只剩中继候选 → **降级回退 relay 并提示“仅中继”**(cdrop 已有兜底;mDNS 失败不可区分“无服务”,UI 须降级)。 +- **【OS 硬】相机权限(持久,扫码登录)**:`NSCameraUsageDescription` 缺失即崩;首启 / 首次扫码时显式询问。**无兜底 → 拒绝则提示“无权限”并阻塞“扫码”特性**(跳系统设置);登录可走 OIDC 等其他途径,阻塞的是扫码、非整个 app。 +- **【OS 硬】Info.plist 用途说明缺失即崩**:相机、相册键(若选图)必声明;剪贴板**无** iOS plist key(横幅系统自动、关不掉)。 +- **【OS 硬】ATS 强制 HTTPS / TLS1.2+**:cdrop 已全 https,无需例外。 +- **【OS 硬】剪贴板**:后台不可读、读内容弹横幅(关不掉)、写不弹、`hasStrings` 探测不弹、`UIPasteControl` 读不弹 → “上”控件接受一次横幅、app 内读用 `UIPasteControl`、“下”控件写无感。 +- **【审核】隐私清单 `PrivacyInfo.xcprivacy` + Required Reason API**(UserDefaults `CA92.1` 等):上架 / TestFlight 才强制;Ad Hoc 非硬性,但第三方 WebRTC SDK 自带清单仍建议补。 + +### 9.3 能力 / 扩展 / 签名 / 分发 + +- **【账号】4 项能力全需付费 ADP**:APNs / App Groups / Keychain 共享 / Associated Domains——免费 Personal Team 一律不支持(决策 B / I0)。 +- **【OS 硬】扩展归属**:Share Extension / ControlWidget 的 bundle id 须**主 app 前缀**、同 Team 签名;与主 app 共享数据**唯一合法途径 = App Group 容器**。 +- **【OS 硬 + 账号】Ad Hoc 100 台 / 年 + UDID 预登记**:旁加载的硬上限与摩擦(决策 B)。 +- **【OS 硬】APNs 环境隔离**:dev profile=sandbox、ad-hoc / 分发=production,token 不互通。 + +--- + +## 附:与桌面端策略对照(修订) + +| 维度 | 桌面(Wails) | iOS(arch A) | |---|---|---| -| UI | 复用 `web/src` | 复用 `web/src` | -| refresh_token | Go keyring(AES-GCM) | iOS Keychain | -| 登录 | loopback PKCE(Go 内) | `ASWebAuthenticationSession`(PKCE) | -| 后台通知 | Go 常驻进程 + 原生 API(窗口非前台才弹) | **服务端 APNs**(app 挂起无常驻进程) | -| 剪贴板 | Go 原生 Monitor 双向实时 | `UIPasteboard`,前台 / 手势(后台受限) | -| 平台判定 | `isDesktop()`(`window.runtime`) | `isIOSShell()`(注入标识) | +| UI | 复用 `web/src` | 原生 SwiftUI(分叉,PARITY 管) | +| 引擎 | Go 原生 | 复用 `web/src` JS(无头) | +| 桥方向 | JS-UI 顶层 → Go | SwiftUI 顶层 → 无头 JS | +| refresh_token | Go keyring | iOS Keychain | +| 登录 | loopback PKCE | `ASWebAuthenticationSession` / 扫码 | +| 后台通知 | Go 常驻进程 + 原生 API | 服务端 APNs | +| 剪贴板 | Go Monitor 实时双向 | 控制中心手动两控件(纯原生 REST) | +| 平台判定 | `isDesktop()`(`window.runtime`) | `isIOSShell()`(`window.webkit.messageHandlers`) | +| 分发 | Dropbox 旁加载 | 旁加载(非 App Store) | diff --git a/web/engine.html b/web/engine.html new file mode 100644 index 0000000..b3acbc0 --- /dev/null +++ b/web/engine.html @@ -0,0 +1,14 @@ + + + + + + cdrop engine + + + + + diff --git a/web/scripts/emit-i18n.mjs b/web/scripts/emit-i18n.mjs new file mode 100644 index 0000000..7b08fcd --- /dev/null +++ b/web/scripts/emit-i18n.mjs @@ -0,0 +1,43 @@ +import { build } from "esbuild"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +// 把 src/i18n/locales/*.ts 的扁平字典产出为 JSON,供 iOS 原生侧读取(强约束②:i18n 单一 +// 真源=web catalog,见 ios/PLAN.md §5)。locale 变更后重跑:node web/scripts/emit-i18n.mjs +// 各 locale 产出完整字典(zh-TW / en-US 缺失键回退 zh-CN),故 iOS loader 无需再做回退。 +const webDir = fileURLToPath(new URL("..", import.meta.url)); +const repoRoot = join(webDir, ".."); +const outDir = join(repoRoot, "ios", "CDrop", "Sources", "Resources", "i18n"); +const cacheDir = join(webDir, "node_modules", ".cache"); +const tmp = join(cacheDir, "cdrop-i18n-emit.mjs"); + +const entry = [ + 'import { zhCN } from "./src/i18n/locales/zh-CN";', + 'import { zhTW } from "./src/i18n/locales/zh-TW";', + 'import { enUS } from "./src/i18n/locales/en-US";', + "export const all = {", + ' "zh-CN": zhCN,', + ' "zh-TW": { ...zhCN, ...zhTW },', + ' "en-US": { ...zhCN, ...enUS },', + "};", +].join("\n"); + +const res = await build({ + stdin: { contents: entry, resolveDir: webDir, loader: "ts" }, + bundle: true, + format: "esm", + write: false, + platform: "neutral", +}); + +await mkdir(cacheDir, { recursive: true }); +await writeFile(tmp, res.outputFiles[0].text); +const mod = await import(pathToFileURL(tmp).href); + +await mkdir(outDir, { recursive: true }); +for (const [loc, dict] of Object.entries(mod.all)) +{ + await writeFile(join(outDir, `${loc}.json`), `${JSON.stringify(dict, null, 2)}\n`); +} +console.log("emitted i18n:", Object.keys(mod.all).join(", "), "->", outDir); diff --git a/web/src/engine/main.ts b/web/src/engine/main.ts new file mode 100644 index 0000000..8553f2d --- /dev/null +++ b/web/src/engine/main.ts @@ -0,0 +1,357 @@ +// cdrop 无头传输引擎入口(arch A,见 ios/PLAN.md §2 / §6)。 +// +// 不加载 React / 路由 / UI——只复用 web 的传输引擎(P2P + relay + 信令 + 会话),由原生 +// iOS 壳经桥(net/ios.ts)驱动。原生在加载本 bundle 前经 WKUserScript 注入 +// window.__CDROP_BOOT__(session / device_name / api_base / device_type:"ios"),store 在 +// import 时即从中水合(见 store/helpers.ts)。本页须从 https 安全源加载——WebRTC 的 +// RTCPeerConnection 在非安全 origin 下可能被禁(见 ios/PLAN.md R-iOS-3)。 +// +// 命令(原生 → 引擎,经 onNativeEvent): +// sendFile { target, url, name, size, type? } —— 取回 url 指向的待发文件并发起传输 +// cancelTransfer { sessionId } +// switchToRelay { sessionId } —— 手动切中继 +// session { access_token, refresh_token?, user } —— 原生重登 / 续期后推新令牌对 +// shutdown —— 断开 hub、停 ICE 刷新 +// 通知(引擎 → 原生,经 notifyNative): +// ready { device } / presence { devices[] } / transfers { active[] } / transferDone {...} +// / sendStarted {...} / error { stage, message } +// sessionRotated { access_token, refresh_token } —— 引擎自刷致 broker 轮换后,回报原生 +// 更新 Keychain(重启免用失效旧 refresh) +// authExpired {} —— 续期被 401 拒(refresh 过期 / 吊销),原生清 Keychain 回登录页 + +import { refreshSessionScope } from "../features/auth/auth"; +import { fetchClipboard, uploadClipboard } from "../features/clipboard/clipboard"; +import { apiFetch } from "../net/api"; +import { startHub } from "../features/transfer/hub"; +import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers"; +import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer"; +import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios"; +import { useAppStore } from "../store"; +import type { TransferRecord } from "../store/types"; + +interface SendFilePayload +{ + target: string; + url: string; + name: string; + size: number; + type?: string; +} + +// 推给原生的精简传输视图:去掉 iceStats 大对象,只留原生 UI 需要的字段 + 一条压缩的 +// ICE 摘要(连接态 + 选中候选对),供详情页诊断 P2P 是否走 TURN 中继(pair 含 "relay" +// 即中继路径,解释慢速)。 +function toWire(r: TransferRecord) +{ + const ice = r.iceStats; + return { + sessionId: r.sessionId, + direction: r.direction, + fileName: r.fileName, + fileSize: r.fileSize, + state: r.state, + mode: r.mode, + peerName: r.peerName, + phase: r.phase, + bytesTransferred: r.bytesTransferred, + bytesPerSec: r.bytesPerSec, + ice: ice + ? { + conn: ice.connection, + local: ice.selectedPair?.local, + remote: ice.selectedPair?.remote, + } + : undefined, + }; +} + +// hub 的生命周期句柄;shutdown 命令据此断开 SSE。 +const hubCtrl = new AbortController(); + +// 活跃传输快照推送节流:接收端每个 64KB chunk 都会更新 store(emitProgress),若每次都 +// 过桥推原生,会(1)让速度数字 ~10Hz 闪烁失去参考价值、(2)主线程被 postMessage/序列化抖 +// 动堆满拖慢 dc.onmessage(见 p2p.ts 收方看门狗注释)→ 反噬 P2P 吞吐。故合并到 ~3Hz +// 尾沿推送:进度条仍流畅、速度可读,主线程负载大幅下降。终态另由 transferDone 即时推。 +const TRANSFERS_MIN_INTERVAL_MS = 350; +let transfersTimer: ReturnType | null = null; +let pendingActive: unknown[] | null = null; +let lastTransfersAt = 0; + +function pushTransfersThrottled(active: unknown[]): void +{ + pendingActive = active; + if (transfersTimer !== null) { return; } + const wait = Math.max(0, TRANSFERS_MIN_INTERVAL_MS - (Date.now() - lastTransfersAt)); + transfersTimer = setTimeout(() => + { + transfersTimer = null; + lastTransfersAt = Date.now(); + notifyNative("transfers", { active: pendingActive }); + pendingActive = null; + }, wait); +} + +// 订阅 store:activeTransfers 引用变化推快照、history 头部新增推完成事件、devices 引用 +// 变化推在线列表。原生据 sessionId / name 幂等更新自己的 UI。setDevices / setActive 每次 +// 都换数组 / 对象引用,故 !== 即可判变更。 +function subscribeStore(): void +{ + const st0 = useAppStore.getState(); + let prevActive = st0.activeTransfers; + let prevDoneId = st0.history[0]?.sessionId; + let prevDevices = st0.devices; + let prevSse = st0.sseConnected; + let prevAuthed = st0.accessToken !== null; + let prevRefresh = st0.refreshToken; + let presenceCount = 0; + + // 把 SSE 连接态 + 收到的 presence 计数推给原生,供设置页诊断「设备空」到底卡在哪: + // SSE 未连接=鉴权/连接问题;已连接但 presence=0=后端没发;presence>0 但列表空=原生侧 + // (已用本地隔离证伪——原生侧 OK)。 + const pushHubState = (s: ReturnType): void => + { + notifyNative("hubState", { + connected: s.sseConnected, + reconnecting: s.sseReconnecting, + presenceCount, + devices: s.devices.length, + }); + }; + + useAppStore.subscribe((s) => + { + if (s.activeTransfers !== prevActive) + { + prevActive = s.activeTransfers; + pushTransfersThrottled(Object.values(s.activeTransfers).map(toWire)); + } + const head = s.history[0]; + if (head && head.sessionId !== prevDoneId) + { + prevDoneId = head.sessionId; + notifyNative("transferDone", toWire(head)); + } + if (s.devices !== prevDevices) + { + prevDevices = s.devices; + presenceCount += 1; + notifyNative("presence", { devices: s.devices }); + pushHubState(s); + } + if (s.sseConnected !== prevSse) + { + prevSse = s.sseConnected; + pushHubState(s); + } + // 引擎自刷(apiFetch 401 → refreshTokens 用注入的 refresh_token 经 /api/auth/refresh + // 续期)后 broker 轮换了 refresh_token。把新令牌对回报原生更新 Keychain,使重启后不再 + // 用已轮换失效的旧 refresh。 + if (s.refreshToken !== prevRefresh && s.refreshToken) + { + prevRefresh = s.refreshToken; + notifyNative("sessionRotated", { + access_token: s.accessToken, + refresh_token: s.refreshToken, + }); + } + // 续期被服务端以 401 拒绝(refresh_token 已过期 / 被吊销)后 forceLogout 清空登录态 + // → 通知原生清 Keychain 回登录页,避免「貌似已登录但拉不到任何数据」的死态。仅在确证 + // 失效(非瞬时)时触发。 + const authed = s.accessToken !== null; + if (prevAuthed && !authed) + { + notifyNative("authExpired", {}); + } + prevAuthed = authed; + }); +} + +// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露,这里取回为 +// File 交给引擎发起传输。 +// +// R-iOS-4:整文件 fetch 进内存——小 / 中文件可行;大文件在 iOS 上有 jetsam 风险,按 +// slice 向原生拉取(不整文件入内存)是真机验证后的优化点,待与原生发送端同期落地。 +async function handleSendFile(p: SendFilePayload): Promise +{ + try + { + const resp = await fetch(p.url); + if (!resp.ok) { throw new Error(`fetch staged file failed: ${resp.status}`); } + const blob = await resp.blob(); + const file = new File([ blob ], p.name, { + type: p.type || blob.type || "application/octet-stream", + }); + const sessionId = await startOutgoingTransfer(p.target, file); + notifyNative("sendStarted", { sessionId, name: p.name }); + } + catch (e) + { + notifyNative("error", { stage: "send", message: e instanceof Error ? e.message : String(e) }); + } +} + +// bindCommands:把原生命令接到引擎函数。 +function bindCommands(): void +{ + onNativeEvent("sendFile", (payload) => + { + void handleSendFile(payload as SendFilePayload); + }); + onNativeEvent("cancelTransfer", (payload) => + { + void cancelTransfer((payload as { sessionId: string }).sessionId); + }); + onNativeEvent("switchToRelay", (payload) => + { + void skipWaitRelay((payload as { sessionId: string }).sessionId); + }); + // 原生 Keychain 续期后推来的新会话令牌 → 写回 store,让 apiFetch 用新令牌(iOS 无 + // 浏览器 cookie / Go 进程,token 续期由原生侧持有 refresh_token 后驱动)。 + onNativeEvent("session", (payload) => + { + const p = payload as { + access_token?: string; + refresh_token?: string; + user?: { id: string; name: string; avatar?: string }; + }; + if (p.access_token && p.user) + { + useAppStore.getState().setAuth({ + accessToken: p.access_token, + refreshToken: p.refresh_token ?? null, + user: p.user, + }); + } + }); + // 剪贴板上行:原生读 UIPasteboard 后把文本送来,经云剪贴板上传(PUT /api/clipboard)。 + onNativeEvent("clipboardUpload", (payload) => + { + const content = (payload as { content?: string }).content ?? ""; + if (!content) { return; } + void uploadClipboard(content) + .then(() => notifyNative("clipboardUploaded", {})) + .catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) })); + }); + // 剪贴板下行:拉一次云剪贴板,把最新内容推回原生写入 UIPasteboard。 + onNativeEvent("clipboardPull", () => + { + void fetchClipboard() + .then(() => + { + const c = useAppStore.getState().clipboard; + notifyNative("clipboard", { content: c?.content ?? "", sourceDevice: c?.sourceDevice ?? "" }); + }) + .catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) })); + }); + // 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{name})。需完整会话;若服务端要求 + // step-up(403)这里拿不到浏览器再认证流程,回错给原生提示「请在网页端完成」。 + onNativeEvent("revokeDevice", (payload) => + { + const name = (payload as { name?: string }).name ?? ""; + if (!name) { return; } + void revokeDevice(name); + }); + onNativeEvent("shutdown", () => + { + hubCtrl.abort(); + stopICEServerRefresh(); + }); +} + +async function revokeDevice(name: string): Promise +{ + try + { + const r = await apiFetch(`/api/devices/${encodeURIComponent(name)}`, { method: "DELETE" }); + if (r.status === 403) + { + notifyNative("error", { stage: "revoke", message: "step_up_required" }); + return; + } + if (!r.ok) + { + notifyNative("error", { stage: "revoke", message: `HTTP ${r.status}` }); + return; + } + notifyNative("deviceRevoked", { name }); + } + catch (e) + { + notifyNative("error", { stage: "revoke", message: String(e) }); + } +} + +// installLogBridge:把 console.warn / error 过桥给原生(设置页显示「最近日志」)。SSE 失败 +// 等都走 console.warn,这样不接 Web Inspector 也能在真机看到失败原因(401 / 网络 / 静默挂起)。 +function installLogBridge(): void +{ + const safeStr = (a: unknown): string => + a instanceof Error + ? `${a.name}: ${a.message}` + : typeof a === "object" && a !== null + ? (() => { try { return JSON.stringify(a); } catch { return String(a); } })() + : String(a); + const wrap = (level: string, orig: (...a: unknown[]) => void) => + (...args: unknown[]): void => + { + try { notifyNative("log", { level, msg: args.map(safeStr).join(" ") }); } + catch { /* 永不让日志桥拖垮引擎 */ } + orig(...args); + }; + console.warn = wrap("warn", console.warn.bind(console)); + console.error = wrap("error", console.error.bind(console)); +} + +// boot:原生壳内启动引擎。store 已于 import 时从 __CDROP_BOOT__ 水合,故此处同步读取 +// 注入的登录态;缺失即报错(原生须在加载本 bundle 前注入)。 +function boot(): void +{ + if (!isIOSShell()) + { + // 非 iOS 壳(误加载 engine.html)——保持惰性,不触发任何网络 / 引擎行为。 + // eslint-disable-next-line no-console + console.warn("cdrop engine: not running inside the iOS shell, idle"); + return; + } + + const { user, selfDeviceName } = useAppStore.getState(); + if (!user || !selfDeviceName) + { + notifyNative("error", { + stage: "boot", + message: "missing injected session / device_name (window.__CDROP_BOOT__)", + }); + return; + } + + installLogBridge(); // 把引擎 console.warn/error 过桥给原生,设置页显示,便于诊断 SSE 失败原因 + bindCommands(); + subscribeStore(); + + void startHub(hubCtrl.signal); // SSE 信令循环:presence / 传入 offer / 状态 / 信令 + void refreshICEServers(); // 预取 TURN / STUN,首次 WebRTC 即可用 + void refreshSessionScope(); // 校正 /api/me 的权限级别 + + // 启动即推一次当前快照:subscribe 只在「之后」的变更触发,初始态(多为空,但桌面壳 + // 复用同入口时可能已有)须主动补发,免原生 UI 等到下一次变更才填。 + const init = useAppStore.getState(); + notifyNative("ready", { device: selfDeviceName }); + notifyNative("presence", { devices: init.devices }); + notifyNative("transfers", { active: Object.values(init.activeTransfers).map(toWire) }); + + // 诊断隔离(仅 CDROP_DEBUG_SESSION 下、prod 用户无此 flag):经真 postMessage 桥推一 + // 条假 presence,验证「桥 → 原生解析 → UI 渲染」整条链路是否工作(不依赖真 SSE/鉴权, + // 因 debug token 必 401 拿不到真 presence)。若原生「设备」据此显示出来 → 链路 OK,空 + // 列表的真因在上游(SSE 没连 / 后端空);若仍不显示 → 原生侧 bug。 + const boot = (window as unknown as { __CDROP_BOOT__?: { debug?: boolean } }).__CDROP_BOOT__; + if (boot?.debug) + { + const nowSec = Math.floor(Date.now() / 1000); + notifyNative("presence", { devices: [ + { name: "Debug-MacBook", type: "macos", online: true, lastSeen: nowSec }, + { name: "Debug-PC", type: "windows", online: false, lastSeen: nowSec - 600 }, + ] }); + } +} + +boot(); diff --git a/web/src/features/auth/auth.ts b/web/src/features/auth/auth.ts index c12bcc9..0171d1c 100644 --- a/web/src/features/auth/auth.ts +++ b/web/src/features/auth/auth.ts @@ -3,15 +3,21 @@ import { t } from "../../i18n"; import { apiFetch } from "../../net/api"; import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop"; import { fetchMeScope } from "../../net/sessions"; -import { stashStepUpPending } from "../qr/stepUp"; import { toast } from "../../ui/feedback"; +// Auth, post Auth Broker migration (path A). cdrop no longer runs OIDC itself: +// - a brand-new browser logs in via the broker's global SSO (loginRedirect → cdrop +// 302s to the broker login page; on return the edge injects X-Auth from the broker +// domain cookie, so this browser holds NO token — the cookie carries identity); +// - a QR-paired device holds the broker's access + refresh pair directly and renews +// them via /api/auth/refresh (a thin same-origin proxy to the broker). + // ---- 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. +// 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. Bypasses the broker; the +// backend dev middleware 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") @@ -26,292 +32,108 @@ export function loginDev(searchParams: URLSearchParams): User } const user: User = { id: devUser, name: devUser }; - useAppStore.getState().setAuth({ accessToken: token, user }); + useAppStore.getState().setAuth({ accessToken: token, refreshToken: null, user }); return user; } +// ---- prod login (broker global SSO) --------------------------------------- + +// loginRedirect sends the browser to the broker's global-SSO login via cdrop's +// server-side 302 (which holds the broker's public URL). After login the browser +// returns here with a broker domain cookie the edge turns into X-Auth. +export function loginRedirect(): void +{ + const rd = encodeURIComponent(window.location.href); + window.location.href = `/api/auth/login?rd=${rd}`; +} + +// ---- logout --------------------------------------------------------------- + export function logout() { - // Fire-and-forget 通知后端立刻把本设备从 Hub 踢掉 + 广播 presence,让对端 - // 不必等 30s 宽限期。apiFetch 同步抓取 access_token 后才让出(fetch 已发出), - // 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略 - // ——只是体验降级回宽限期路径,不影响登出本身。 + // Fire-and-forget: tell the backend to kick this device from the Hub immediately + // (so peers don't wait out the 30s grace). apiFetch reads the access token before + // yielding, so the subsequent clearAuth can't strip this request's Authorization. void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ }); - // 浏览器端:删除服务端 session 并清除 HttpOnly cookie(cookie 自动随同源请求), - // 否则下次开机 bootstrapAuth 仍会静默免重登回来。桌面端无 cookie session,跳过。 - if (!isDesktop() && useAppStore.getState().authMode === "prod") + // Revoke this device's own broker session server-side (self-service logout), so it + // can't be refreshed back. For a global-SSO browser this is a no-op (nothing to + // revoke). Best-effort. + if (useAppStore.getState().authMode === "prod") { - void fetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ }); + void apiFetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ }); } useAppStore.getState().clearAuth(); - // 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。 + // Desktop: drop the Go-side persisted session, else the next launch re-injects it. if (isDesktop()) { clearDesktopSession(); } } -// forceLogout 在「服务端确证会话已失效」时清掉本地登录态——触发点仅一处: -// access token 401 后续期也被服务端以 401 拒绝(refreshTokens 返回 "invalid", -// 即被远程吊销或已过期)。短期连接失败(网络抖动 / 5xx)绝不走到这里(见 -// net/api.ts)。与 logout() 不同,它不发任何服务端请求(会话已死,调用只会再次 -// 401),只就地清空 + 提示,让 __root 的反应式守卫把界面送回登录页 -// (离线设备「下一次使用即得知」)。 -// -// 幂等:靠「已登出即跳过」实现——首个 401 同步清空 user/accessToken 后,并发涌入 -// 的其余 401 看到已为空便直接返回,不会重复弹 toast、不重复清桌面 session。 +// forceLogout clears local auth state when the server authoritatively confirms the +// session is gone (refresh → 401). It sends no request (the session is dead) and just +// clears + toasts; the __root guard then returns the UI to the login page. Idempotent: +// concurrent 401s after the first clear see an empty state and return early. export function forceLogout(): void { const st = useAppStore.getState(); if (!st.user && !st.accessToken) { - // 已被并发的某个 401 清空,或本就处于登出态:不重复处理(避免 toast 刷屏)。 return; } st.clearAuth(); - // 桌面端:删除 Go 侧持久化 session,否则下次启动仍注入这枚已失效的登录态。 if (isDesktop()) { clearDesktopSession(); } toast.error(t("auth.sessionLost.title"), t("auth.sessionLost.body")); } -// ---- prod (OIDC PKCE) ----------------------------------------------------- +// ---- token refresh (broker proxy) ----------------------------------------- -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 -{ - 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"; - -// step-up 再认证用一对独立的 state / verifier 句柄,绝不复用上面那对常规登录键—— -// 否则 /oauth/callback 在 step-up 往返里会被当成普通登录、误调 /api/auth/exchange。 -// state 走 sessionStorage(仅本次往返、关标签即清,与 step-up 暂存物同生命周期); -// verifier 不单独落键,而是塞进 stepUp.ts 的 PENDING({verifier, returnTo})。 -const STEPUP_STATE_KEY = "cdrop.qr.stepup_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 -{ - 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}`; -} - -// startStepUpReauth 发起批准页 step-up 再认证:一次新鲜 PKCE 往返,但追加 -// prompt=login + max_age=0 强制 provider 现场重证(2FA 即在此处过)。复用既有 -// /oauth/callback 作 redirect_uri;跳转前用 sessionStorage 记下「待办」标记 + -// 这次的 verifier + 要回到的批准页 URL(returnTo 走 stepUp.ts 的同款 open-redirect -// 校验,仅允许 /link)。回调检测到「待办」即分叉,不消费 code(见 oauth.callback)。 -// -// 注意:这里不写常规 PKCE_VERIFIER_KEY / OAUTH_STATE_KEY,否则普通登录回调会误判。 -// verifier 进 PENDING、state 进 STEPUP_STATE_KEY,二者均会在回调与批准页里清掉。 -export async function startStepUpReauth(returnTo: string): Promise -{ - 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); - - // 先把「待办」落定(含 returnTo 的 open-redirect 校验);非法即拒,不发起跳转。 - if (!stashStepUpPending({ verifier, returnTo })) - { - throw new Error("invalid step-up return path"); - } - sessionStorage.setItem(STEPUP_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", - prompt: "login", - max_age: "0", - }); - window.location.href = `${cfg.authorize_url}?${params}`; -} - -// verifyStepUpState 在 /oauth/callback 的 step-up 分叉里校 state(防 CSRF,与常规 -// 登录回调同口径)。匹配即消费掉 STEPUP_STATE_KEY 并返回 true;不匹配返回 false。 -export function verifyStepUpState(state: string): boolean -{ - const expected = sessionStorage.getItem(STEPUP_STATE_KEY); - sessionStorage.removeItem(STEPUP_STATE_KEY); - return !!expected && state === expected; -} - -// clearStepUpState 兜底清掉 step-up state(异常路径,避免残留)。 -export function clearStepUpState(): void -{ - sessionStorage.removeItem(STEPUP_STATE_KEY); -} - -interface ExchangeResp +interface BrokerRefreshResp { access_token: string; + refresh_token: string; expires_in: number; - user: { id: string; name: string; avatar?: string }; - // refresh_token 不再回传:服务端把它加密存进 web_sessions,浏览器只拿到一枚 - // HttpOnly 的 session cookie(随 /api/auth/exchange 响应一并下发)。 } -// 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 -{ - 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, - user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar }, - }); -} - -interface CookieRefreshResp -{ - access_token: string; - expires_in: number; - user: { id: string; name: string; avatar?: string }; - device_name?: string; -} - -// CookieRefreshResult 区分「服务端确证会话失效」与「短期失败」,使上层只在前者 -// 清空登录态: -// ok —— 换到新 access token -// invalid —— /api/auth/refresh 返回 401:服务端查证会话已失效 / 被吊销 / 过期 -// (其处理器同时清掉 cookie),凭据确凿不可恢复 -// transient —— 5xx / 同源校验 403 / 响应残缺等非确证失败(网络层抛错则根本不进此 -// 函数、照常向上抛)——一律保留登录态,按短期错误处理 -type CookieRefreshResult = - | { kind: "ok"; accessToken: string; user: User; deviceName: string } +type BrokerRefreshResult = + | { kind: "ok"; accessToken: string; refreshToken: string } | { kind: "invalid" } | { kind: "transient" }; -// cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body—— -// cookie 自动随同源请求发出(Path=/api/auth)。返回见 CookieRefreshResult:只有 -// 服务端明确以 401 拒绝才算 invalid,短期失败一律 transient,不误伤登录态。 -async function cookieRefresh(): Promise +// brokerRefresh exchanges the stored refresh token for a fresh access + rotated +// refresh via cdrop's same-origin proxy to the broker. 401 → invalid (expired / +// rotated / revoked); other non-OK / malformed → transient (keep the session). +async function brokerRefresh(refreshToken: string): Promise { - const r = await fetch("/api/auth/refresh", { method: "POST" }); - if (!r.ok) + let r: Response; + try { - return { kind: r.status === 401 ? "invalid" : "transient" }; + r = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); } - const data = await r.json().catch(() => null) as CookieRefreshResp | null; - if (!data?.access_token || !data.user?.id) { return { kind: "transient" }; } - return { - kind: "ok", - accessToken: data.access_token, - user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar }, - deviceName: data.device_name ?? "", - }; + catch + { + return { kind: "transient" }; + } + if (!r.ok) { return { kind: r.status === 401 ? "invalid" : "transient" }; } + const data = await r.json().catch(() => null) as BrokerRefreshResp | null; + if (!data?.access_token || !data.refresh_token) { return { kind: "transient" }; } + return { kind: "ok", accessToken: data.access_token, refreshToken: data.refresh_token }; } -// RefreshOutcome 是续期对上层的三态结论;只有 "invalid"(服务端确证失效)才允许 -// 上层 forceLogout,"transient" 必须保留登录态(短期连接失败不清状态)。 +// RefreshOutcome is the three-state conclusion refresh reports upward; only "invalid" +// (server-confirmed dead) lets the caller forceLogout, "transient" must keep state. export type RefreshOutcome = "refreshed" | "invalid" | "transient"; -// refreshTokens 换新 access_token;api.ts 在请求拿到 401 时惰性调用。 +// refreshTokens renews the access token; api.ts calls it lazily on a 401. export async function refreshTokens(): Promise { const store = useAppStore.getState(); if (store.authMode !== "prod") { return "transient"; } - // 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有), - // 直接无参调用。Go 侧暂不区分「被吊销」与「短期失败」,故失败一律按 transient - // 处理(不清空登录态)——桌面端即时吊销留后续(AUTH.md §3.1)。 + // Desktop: refresh happens entirely inside Go (the refresh token lives in the Go + // process, never in JS). Go does not yet distinguish revoked from transient, so a + // failure is treated as transient (keeps state). if (isDesktop()) { const res = await desktopRefresh(); @@ -322,78 +144,210 @@ export async function refreshTokens(): Promise return "refreshed"; } - // 浏览器端:走 HttpOnly cookie session(refresh_token 在服务端,JS 不持有)。 - const res = await cookieRefresh(); - if (res.kind !== "ok") { return res.kind; } // "invalid" | "transient",透传给上层 + // After 代铸 a browser holds a device-session refresh token and renews via the broker + // proxy below. A browser still on cookie-only identity (代铸 not yet run / failed) holds + // none; a 401 then means the broker cookie is gone too: invalid → re-login. + const refresh = store.refreshToken; + if (!refresh) { return "invalid"; } + + const res = await brokerRefresh(refresh); + if (res.kind !== "ok") { return res.kind; } const cur = useAppStore.getState(); - cur.setAuth({ accessToken: res.accessToken, user: res.user }); - // 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。 - if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); } + if (!cur.user) { return "transient"; } + cur.setAuth({ accessToken: res.accessToken, refreshToken: res.refreshToken, user: cur.user }); return "refreshed"; } -// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token, -// 用 HttpOnly cookie 静默续期,成功即直接进入已登录态(并回填服务端所记设备名), -// 失败则照常落到登录页。桌面端由注入式水合负责、dev 无 cookie 流程,均直接跳过。 -export async function bootstrapAuth(): Promise -{ - if (isDesktop()) { return; } - const store = useAppStore.getState(); - if (store.authMode !== "prod") { return; } - if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中) +// ---- session bootstrap (/api/me) ------------------------------------------ - const res = await cookieRefresh(); - if (res.kind !== "ok") { return; } // 开机水合失败(含 invalid / transient)→ 照常落登录页 - const cur = useAppStore.getState(); - cur.setAuth({ accessToken: res.accessToken, user: res.user }); - if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); } +interface MeResp +{ + user_id: string; + name: string; + avatar?: string; + device_name?: string; + scope?: string; } -// refreshSessionScope 拉一次 /api/me 校正本会话权限级别(full / guest)并写进 -// store。在登录成功 / 开机水合后调用一次,使受限访客的账号管理入口被隐藏。失败 -// 静默回退 "full"——只多展示入口,后端仍 403 兜底。dev 模式无 guest 概念,跳过。 +// fetchMe confirms the session with the server: a QR device sends its stored Bearer, a +// global-SSO browser relies on the broker cookie (no header). Returns null on any +// non-200 / error, so the caller falls to the login page. +async function fetchMe(): Promise +{ + const token = useAppStore.getState().accessToken; + const headers: Record = {}; + if (token) { headers.Authorization = `Bearer ${token}`; } + let r: Response; + try { r = await fetch("/api/me", { headers }); } + catch { return null; } + if (!r.ok) { return null; } + return await r.json().catch(() => null) as MeResp | null; +} + +// bootstrapAuth runs before the app mounts: it confirms the session via /api/me and +// hydrates the identity (a stored device token is preserved; a global-SSO browser has +// none). Failure leaves the app logged out → login page. Desktop hydrates from boot +// injection and dev has no broker session; both skip. +export async function bootstrapAuth(): Promise +{ + const store = useAppStore.getState(); + if (store.authMode !== "prod") { return; } + + if (isDesktop()) + { + // Desktop identity (name + avatar) comes from the Go boot injection, which heals a stale + // persisted identity from /api/me — refreshing the token if needed and re-persisting — + // BEFORE injecting it (see desktop HealIdentity). Doing it Go-side, at the source, is why + // it no longer depends on a fragile per-launch WebView /api/me. Nothing to do here. + return; + } + + const me = await fetchMe(); + if (!me?.user_id) { return; } + const cur = useAppStore.getState(); + // Use /api/me's name only when it is a real display name. The server falls back to the + // subject UUID when X-Auth-Name is absent; in that case keep an already-known name (e.g. + // rehydrated from a prior login) rather than clobbering it with the UUID. + const verifiedName = me.name && me.name !== me.user_id ? me.name : ""; + cur.setAuth({ + accessToken: cur.accessToken, // preserve a stored device token, or null (cookie) + user: { + id: me.user_id, + name: verifiedName || cur.user?.name || me.user_id, + avatar: me.avatar || cur.user?.avatar, + }, + }); + cur.setSessionScope(me.scope === "guest" ? "guest" : "full"); + if (me.device_name && !cur.selfDeviceName) { cur.setSelfDeviceName(me.device_name); } + // Upgrade a global-SSO browser into a cdrop-managed device session (代铸) so it joins + // the unified device list and rides a Bearer token like a QR-paired device. No-op if it + // already holds a token or has no device name yet (first-time setup mints it on naming). + await ensureDeviceSession(); +} + +// ---- 代铸 (managed device session) ---------------------------------------- + +// DEVICE_ID_KEY persists this browser's stable cdrop device_id (the broker `meta` / +// session<->device join key). It survives sessionStorage token loss so a re-login rotates +// the same session (broker R2 idempotency) instead of spawning a duplicate device. +const DEVICE_ID_KEY = "cdrop.device_id"; + +interface DeviceSessionResp +{ + access_token: string; + refresh_token: string; + expires_in: number; + device_id: string; + device_name: string; + name: string; +} + +// ensureDeviceSession turns a global-SSO browser (cookie identity, no token) into a +// cdrop-managed device session: the backend 代铸s a session bound to this browser's stable +// device_id, so the browser appears in the unified device list exactly like a QR-paired +// device and rides a Bearer token thereafter. Idempotent and best-effort: +// - dev / desktop / not-logged-in → skip; +// - already holds a token (QR device or a prior 代铸) → skip; +// - no device name yet → skip (first-time setup calls this again after naming); +// - on any failure the browser keeps working via the broker cookie (degraded: it just +// won't show in the device list until a later 代铸 succeeds). +export async function ensureDeviceSession(): Promise +{ + const st = useAppStore.getState(); + if (st.authMode !== "prod" || isDesktop()) { return; } + if (!st.user) { return; } + if (st.accessToken) { return; } + if (!st.selfDeviceName) { return; } + + // Serialize 代铸 across all same-origin tabs. Without this, two tabs (or a reload before the + // first mint persists device_id) each 代铸 with an EMPTY device_id, and the server mints a + // fresh id for each → duplicate phantom devices (the very regression this rework fixes). The + // first lock holder mints and persists device_id; later holders read the seeded id and the + // broker R2-rotates the one session. Web Locks coordinate across tabs; absent the API, fall + // back to a best-effort single call. + // Wrap in an arrow so the LockManager callback's Lock argument is not passed on as the + // mintDeviceSession `force` flag. + if (typeof navigator !== "undefined" && navigator.locks) + { + await navigator.locks.request("cdrop.device-session", () => mintDeviceSession()); + } + else + { + await mintDeviceSession(); + } +} + +// renameDeviceSession re-代铸s with the (already-updated) selfDeviceName and the same stable +// device_id, so the broker label — hence the unified device list and presence — reflects the +// new name. R2 rotates the one session in place (no duplicate device row); the rotated tokens +// replace the browser's current ones. No-op unless this browser already holds a device session +// (a cookie-only browser adopts the name at its first 代铸; desktop renames in its own client). +export async function renameDeviceSession(): Promise +{ + const st = useAppStore.getState(); + if (st.authMode !== "prod" || isDesktop()) { return; } + if (!st.user || !st.accessToken || !st.selfDeviceName) { return; } + await mintDeviceSession(true); +} + +async function mintDeviceSession(force = false): Promise +{ + // Re-read inside the lock: another tab may have just seeded device_id (or this tab may have + // gained a token meanwhile). force=true is the rename path: re-mint even though a token is + // already held, to push the new label to the broker. + const st = useAppStore.getState(); + if (!force && st.accessToken) { return; } + const deviceName = st.selfDeviceName; + if (!deviceName) { return; } + + const storedId = window.localStorage.getItem(DEVICE_ID_KEY) ?? ""; + let r: Response; + try + { + // Raw fetch (not apiFetch): identity rides the broker cookie, and a 401 here must + // not trigger the refresh→forceLogout path (there is no refresh token yet). + r = await fetch("/api/auth/device-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_id: storedId, + device_name: deviceName, + device_type: "browser", + }), + }); + } + catch { return; } + if (!r.ok) { return; } + const data = await r.json().catch(() => null) as DeviceSessionResp | null; + if (!data?.access_token || !data.device_id) { return; } + + window.localStorage.setItem(DEVICE_ID_KEY, data.device_id); + const cur = useAppStore.getState(); + if (!cur.user) { return; } + cur.setAuth({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + user: cur.user, + }); +} + +// refreshSessionScope re-reads /api/me to correct the session's capability level +// (full / guest) into the store, so a restricted guest's account-management entries +// stay hidden. Best-effort; failure falls back to "full" (entries only show, backend +// still 403s). dev has no guest concept; skip. export async function refreshSessionScope(): Promise { const store = useAppStore.getState(); if (store.authMode !== "prod") { return; } - if (!store.user || !store.accessToken) { return; } + if (!store.user) { return; } const scope = await fetchMeScope(); useAppStore.getState().setSessionScope(scope); } -// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清 -// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走 -// persistDesktopDeviceName(Go 持久化),dev 无 session。失败静默——只是体验降级。 -export function syncWebDeviceName(name: string): void +// syncWebDeviceName is retained as a no-op after the migration: a QR-paired device's +// name is set server-side at pairing (and renamed via the device API); a global-SSO +// browser keeps no managed device row. The local selfDeviceName still drives presence. +export function syncWebDeviceName(_name: string): void { - if (isDesktop()) { return; } - if (useAppStore.getState().authMode !== "prod") { return; } - void fetch("/api/auth/device", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ device_name: name }), - }).catch(() => { /* ignore */ }); -} - -// ---- PKCE helpers --------------------------------------------------------- - -function generateRandomBase64url(numBytes: number): string -{ - const arr = new Uint8Array(numBytes); - crypto.getRandomValues(arr); - return base64urlEncode(arr); -} - -async function sha256Base64url(input: string): Promise -{ - 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(/=+$/, ""); + /* no-op: see comment above */ } diff --git a/web/src/features/qr/stepUp.ts b/web/src/features/qr/stepUp.ts deleted file mode 100644 index 4c47fcf..0000000 --- a/web/src/features/qr/stepUp.ts +++ /dev/null @@ -1,128 +0,0 @@ -// 批准页 step-up 再认证的会话级暂存。step_up 开启时,批准方在批准新设备前必须做 -// 一次新鲜的 prompt=login 再登录(provider 若开了 2FA 即在此处过),把这次拿到的 -// 授权码交给后端就地核验。这段往返跨一次整页跳转(→ provider → /oauth/callback → -// 批准页),故用 sessionStorage 串起三个状态: -// -// 1. 跳转去 provider 前:记下「待办」标记 + 这次的 code_verifier + 要回到的批准页 -// URL(PENDING)。 -// 2. /oauth/callback 落地(检测到 PENDING):不调 /api/auth/exchange(那会在服务端 -// 消费掉 code 并轮换会话),转而把本次回调的 {code, verifier} 暂存进 STASH, -// 清掉 PENDING,整页跳回批准页 URL。 -// 3. 批准页回到后:取出 STASH 的 {code, verifier} → 调 qrApprove 就地核验 → 用完即清。 -// -// sessionStorage(非 localStorage):仅本次再认证往返需要,关标签即清、不跨设备; -// code 是一次性的且与本批准会话强绑定,落 localStorage 反而徒增泄漏面。 - -const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo } -const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier } -// 登录会话登出的 step-up:跨整页跳转记住「要登出的是哪条会话」。回到设置页后取出, -// 重试那条 DELETE。批准页的 step-up 不用它(要批准的 request 已编在 returnTo 里)。 -const REVOKE_KEY = "cdrop.qr.stepup_revoke"; // sessionId(string) - -// 仅允许两个站内回流目标,杜绝 open-redirect(外部 URL / 协议相对地址 / 任意路径): -// - /link[?…] ——批准页(扫码登录的 step-up); -// - /settings ——设置页(登录会话登出的 step-up)。 -// step-up 往返回来后的整页跳转目标只能是这两处之一;其余一律拒。校验 path 必须以 -// "/link" 或 "/settings" 起头且后随串只能是空或 "?…",挡住 "/linkmalicious" / -// "//evil.com"(协议相对)/ "/settings/../x" 之类绕过。 -function isSafeReturnPath(path: string): boolean -{ - for (const base of [ "/link", "/settings" ]) - { - if (path === base) { return true; } - if (path.startsWith(base + "?")) { return true; } - } - return false; -} - -export interface StepUpPending -{ - verifier: string; - returnTo: string; -} - -export interface StepUpStash -{ - code: string; - verifier: string; -} - -// stashStepUpPending 在跳转去 provider 前记下「待办」。returnTo 非法(防开放重定向) -// 即不写——调用方据返回值判定是否安全发起跳转。 -export function stashStepUpPending(pending: StepUpPending): boolean -{ - if (typeof window === "undefined") { return false; } - if (!pending.verifier || !isSafeReturnPath(pending.returnTo)) { return false; } - window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending)); - return true; -} - -// peekStepUpPending 只读地探测是否在 step-up 往返中(/oauth/callback 据此分叉, -// 决定不走 /api/auth/exchange)。不消费。 -export function peekStepUpPending(): StepUpPending | null -{ - if (typeof window === "undefined") { return null; } - const raw = window.sessionStorage.getItem(PENDING_KEY); - if (!raw) { return null; } - const p = safeParse(raw); - if (!p?.verifier || !isSafeReturnPath(p.returnTo)) { return null; } - return p; -} - -export function clearStepUpPending(): void -{ - if (typeof window === "undefined") { return; } - window.sessionStorage.removeItem(PENDING_KEY); -} - -// stashStepUpCode 把回调拿到的一次性 code + 配套 verifier 暂存,留给批准页交给 -// 后端 approve 核验。前端绝不自己 POST /exchange 消费它。 -export function stashStepUpCode(stash: StepUpStash): void -{ - if (typeof window === "undefined") { return; } - if (!stash.code || !stash.verifier) { return; } - window.sessionStorage.setItem(STASH_KEY, JSON.stringify(stash)); -} - -// consumeStepUpCode 取出并清除暂存的 {code, verifier}(一次性,用完即清,避免泄漏 -// 与复用)。无 / 非法返回 null。 -export function consumeStepUpCode(): StepUpStash | null -{ - if (typeof window === "undefined") { return null; } - const raw = window.sessionStorage.getItem(STASH_KEY); - window.sessionStorage.removeItem(STASH_KEY); - if (!raw) { return null; } - const s = safeParse(raw); - if (!s?.code || !s.verifier) { return null; } - return s; -} - -export function clearStepUpStash(): void -{ - if (typeof window === "undefined") { return; } - window.sessionStorage.removeItem(STASH_KEY); -} - -// stashPendingRevoke 在发起会话登出的 step-up 再认证前记下待登出的 sessionId, -// 跨整页跳转留到回到设置页后取出重试。 -export function stashPendingRevoke(sessionId: string): void -{ - if (typeof window === "undefined") { return; } - if (!sessionId) { return; } - window.sessionStorage.setItem(REVOKE_KEY, sessionId); -} - -// consumePendingRevoke 取出并清除待登出的 sessionId(一次性,用完即清)。 -export function consumePendingRevoke(): string | null -{ - if (typeof window === "undefined") { return null; } - const id = window.sessionStorage.getItem(REVOKE_KEY); - window.sessionStorage.removeItem(REVOKE_KEY); - return id || null; -} - -function safeParse(raw: string): T | null -{ - try { return JSON.parse(raw) as T; } - catch { return null; } -} diff --git a/web/src/features/transfer/incomingSink.ts b/web/src/features/transfer/incomingSink.ts index 60dd187..8ecd139 100644 --- a/web/src/features/transfer/incomingSink.ts +++ b/web/src/features/transfer/incomingSink.ts @@ -27,6 +27,14 @@ import { isDesktop, saveIncomingFileDesktop, } from "../../net/desktop"; +import { + abortIncomingDownloadIOS, + appendIncomingDownloadIOS, + beginIncomingDownloadIOS, + finalizeIncomingDownloadIOS, + isIOSShell, + saveIncomingFileIOS, +} from "../../net/ios"; import { toast } from "../../ui/feedback"; // close() 两态结果:blob=整文件在内存,交给 downloadBlob;saved=桌面大文件已流式 @@ -45,9 +53,13 @@ export interface IncomingSink cancel(): Promise; } -// 桌面混合 sink 的内存上限与 spill 批大小:暂存达 CAP 即转流式落盘,之后每满一批刷。 +// 混合 sink 的内存上限与 spill 批大小:暂存达 CAP 即转流式落盘,之后每满一批刷。 +// 桌面 RAM 充裕、上限放宽;iOS 受 jetsam 限制(无预警直接 kill 进程),更早 spill、 +// 批更小,避免接收大文件时内存峰值触发 kill。 const DESKTOP_MEMORY_CAP = 256 * 1024 * 1024; const DESKTOP_FLUSH_BATCH = 64 * 1024 * 1024; +const IOS_MEMORY_CAP = 64 * 1024 * 1024; +const IOS_FLUSH_BATCH = 16 * 1024 * 1024; /** * 按环境选 sink:桌面走混合有界内存槽(见文件头注释),浏览器优先 OPFS、失败回退 @@ -60,6 +72,10 @@ export async function openIncomingSink(sessionId: string, fileName: string): Pro { return openHybridDesktopSink(sessionId, fileName); } + if (isIOSShell()) + { + return openHybridIOSSink(sessionId, fileName); + } // 注意:直接 navigator.storage?.getDirectory 在 TS 下永远 truthy(optional // chaining 取的是函数引用,不会调用)。要真探测必须 try-catch 调用。 if (typeof navigator !== "undefined" && navigator.storage) @@ -74,38 +90,55 @@ export async function openIncomingSink(sessionId: string, fileName: string): Pro return openMemorySink(); } +// 混合有界内存槽的存储后端配置:内存上限 / spill 批大小 + 四个落盘桥方法(begin / +// append / finalize / abort,与桌面、iOS 各自的原生桥同形)。 +interface StreamingSinkConfig +{ + cap: number; + batch: number; + begin: (sessionId: string) => Promise; + append: (sessionId: string, bytes: Uint8Array) => Promise; + finalize: (sessionId: string, name: string) => Promise; + abort: (sessionId: string) => Promise; +} + /** - * 桌面混合有界内存槽。小文件(暂存 < CAP)纯内存累积,close 时返回 Blob 交给 - * downloadBlob(= 既有桌面整文件过 Go 写盘路径);一旦暂存越过 CAP,转流式落盘—— - * 把已暂存的全部按 ≤批大小刷给 Go、释放内存,之后每满一批再刷,内存恒定有界。 + * 混合有界内存槽(桌面与 iOS 共用,只换落盘后端)。小文件(暂存 < cap)纯内存累积, + * close 时返回 Blob 交给 downloadBlob(= 整文件过原生桥写盘路径);一旦暂存越过 cap, + * 转流式落盘——把已暂存的全部按 ≤批大小刷给原生、释放内存,之后每满一批再刷,内存 + * 恒定有界。 * * write() 由调用方(p2p / relay 的 receiveQueue promise 链)严格串行调用,故内部 * 无需再加锁。 */ -function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSink +function openHybridStreamingSink( + sessionId: string, + fileName: string, + cfg: StreamingSinkConfig, +): IncomingSink { let pending: ArrayBuffer[] = []; let pendingBytes = 0; let spilled = false; let closed = false; - // 从 pending 头部取出至多 DESKTOP_FLUSH_BATCH 字节拼成一块 append 给 Go。 - // drainAll=true 把剩余全部刷完(spill 起始 / close);否则只在满一批时刷。 + // 从 pending 头部取出至多 cfg.batch 字节拼成一块 append 给原生。drainAll=true 把 + // 剩余全部刷完(spill 起始 / close);否则只在满一批时刷。 const flush = async (drainAll: boolean): Promise => { - while (pendingBytes >= DESKTOP_FLUSH_BATCH || (drainAll && pendingBytes > 0)) + while (pendingBytes >= cfg.batch || (drainAll && pendingBytes > 0)) { let take = 0; const batch: ArrayBuffer[] = []; while (pending.length > 0 - && (take === 0 || take + pending[0].byteLength <= DESKTOP_FLUSH_BATCH)) + && (take === 0 || take + pending[0].byteLength <= cfg.batch)) { const c = pending.shift() as ArrayBuffer; batch.push(c); take += c.byteLength; } pendingBytes -= take; - await appendIncomingDownloadDesktop(sessionId, concatChunks(batch, take)); + await cfg.append(sessionId, concatChunks(batch, take)); } }; @@ -120,11 +153,11 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin if (!spilled) { - if (pendingBytes >= DESKTOP_MEMORY_CAP) + if (pendingBytes >= cfg.cap) { - // 越过内存上限:开始流式落盘,把已暂存的全部刷给 Go,转滚动批。 + // 越过内存上限:开始流式落盘,把已暂存的全部刷给原生,转滚动批。 spilled = true; - await beginIncomingDownloadDesktop(sessionId); + await cfg.begin(sessionId); await flush(true); } return; @@ -141,7 +174,7 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin return { kind: "blob", blob: new Blob(pending) }; } await flush(true); - const path = await finalizeIncomingDownloadDesktop(sessionId, fileName); + const path = await cfg.finalize(sessionId, fileName); return { kind: "saved", path }; }, async cancel() @@ -149,11 +182,37 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin closed = true; pending = []; pendingBytes = 0; - if (spilled) { await abortIncomingDownloadDesktop(sessionId); } + if (spilled) { await cfg.abort(sessionId); } }, }; } +// 桌面后端(Wails → Go)。 +function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSink +{ + return openHybridStreamingSink(sessionId, fileName, { + cap: DESKTOP_MEMORY_CAP, + batch: DESKTOP_FLUSH_BATCH, + begin: beginIncomingDownloadDesktop, + append: appendIncomingDownloadDesktop, + finalize: finalizeIncomingDownloadDesktop, + abort: abortIncomingDownloadDesktop, + }); +} + +// iOS 后端(WKWebView 桥 → 原生沙盒)。内存上限更小,避免 jetsam(见 IOS_MEMORY_CAP)。 +function openHybridIOSSink(sessionId: string, fileName: string): IncomingSink +{ + return openHybridStreamingSink(sessionId, fileName, { + cap: IOS_MEMORY_CAP, + batch: IOS_FLUSH_BATCH, + begin: beginIncomingDownloadIOS, + append: appendIncomingDownloadIOS, + finalize: finalizeIncomingDownloadIOS, + abort: abortIncomingDownloadIOS, + }); +} + // concatChunks 把若干 ArrayBuffer 拼成一个 Uint8Array(供 base64 过桥)。 function concatChunks(parts: ArrayBuffer[], totalBytes: number): Uint8Array { @@ -275,6 +334,25 @@ export function downloadBlob(blob: Blob, fileName: string, sessionId?: string): }); return; } + // iOS 无头壳同理:整文件经桥交原生写入沙盒下载目录;WKWebView 无浏览器下载回退, + // 故失败必须显式报错、绝不静默丢文件(同桌面)。 + if (isIOSShell()) + { + void saveIncomingFileIOS(fileName, blob) + .then((savedPath) => + { + toast.ok(t("transfer.savedTo", { path: savedPath })); + if (sessionId) { void cleanupOPFS(sessionId); } + }) + .catch((e) => + { + // eslint-disable-next-line no-console + console.error("ios save failed", e); + toast.error(t("transfer.saveFailed", { error: e instanceof Error ? e.message : String(e) })); + if (sessionId) { void cleanupOPFS(sessionId); } + }); + return; + } browserDownload(blob, fileName, sessionId); } diff --git a/web/src/features/transfer/p2p.ts b/web/src/features/transfer/p2p.ts index aeb9797..40d4407 100644 --- a/web/src/features/transfer/p2p.ts +++ b/web/src/features/transfer/p2p.ts @@ -23,6 +23,11 @@ const CHANNEL_NAME = "cdrop-file"; const CHUNK_SIZE = 64 * 1024; const HIGH_WATERMARK = 16 * 1024 * 1024; const LOW_WATERMARK = 4 * 1024 * 1024; +// 发送端「读写解耦」:一次读 4 MiB 进内存,再从内存切 CHUNK_SIZE 子片 dc.send。原先 +// 每 64 KiB 都 await file.slice().arrayBuffer(),逐片异步文件读在 iOS Safari 上极慢(实测 +// 直连 P2P 仅 ~250 KB/s,远低于中继 3 MB/s)。块读把异步文件读次数降 ~64 倍,SCTP 消息 +// 大小与流控(HIGH/LOW watermark)完全不变,接收端无感。 +const READ_BLOCK_SIZE = 4 * 1024 * 1024; const ICE_CANDIDATE_POOL = 4; export interface FileMeta @@ -498,49 +503,65 @@ class Session // "已交付字节"随接收端实际收程平滑爬升、lastProgressAt 保持新鲜,不误报"疑似卡住"。 this.startSendProgressPoll(); + // 发送块大小固定 64 KiB:实测放大到 256 KiB 在 iOS Safari 上反而把吞吐打到 + // <100 KB/s(SCTP 大消息分片/重组代价),故维持 CHUNK_SIZE。读写解耦(块读)保留。 + const sendChunk = CHUNK_SIZE; + let offset = 0; while (offset < file.size) { - // 预测式检查:在 send 之前看"加了这一片之后会不会撞上 HIGH"。 - // Chrome WebRTC 的 RTCDataChannel.send 在 bufferedAmount + size 超过 - // kMaxQueuedSendDataBytes(16 MiB)时直接抛 OperationError;我们 HIGH - // 也设 16 MiB,原本的 `bufferedAmount > HIGH` 检查在 buffer = HIGH - 1 - // 时不触发,下一片就会跨过硬上限挂掉。 - if (dc.bufferedAmount + CHUNK_SIZE > HIGH_WATERMARK) + // 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。 + const blockEnd = Math.min(offset + READ_BLOCK_SIZE, file.size); + const block = new Uint8Array(await file.slice(offset, blockEnd).arrayBuffer()); + + // 从内存块按 sendChunk 子片发送;流控(bufferedAmount/水位)逻辑不变。 + let inBlock = 0; + while (inBlock < block.byteLength) { - await waitForBufferLow(dc, LOW_WATERMARK); - } - const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size)); - const buf = await slice.arrayBuffer(); - try - { - dc.send(buf); - } - catch (e) - { - // 兜底:极端情况下 bufferedAmount 在 await 与 send 之间漂移,仍可能 - // 撞到 OperationError。退一步等 buffer 抽干到 LOW 再重试一次;仍失 - // 败说明通道本身有问题,让外层 .catch 走 markServerFail。 - if (e instanceof DOMException && e.name === "OperationError") + // 预测式检查:在 send 之前看"加了这一片之后会不会撞上 HIGH"。 + // Chrome WebRTC 的 RTCDataChannel.send 在 bufferedAmount + size 超过 + // kMaxQueuedSendDataBytes(16 MiB)时直接抛 OperationError;我们 HIGH + // 也设 16 MiB,原本的 `bufferedAmount > HIGH` 检查在 buffer = HIGH - 1 + // 时不触发,下一片就会跨过硬上限挂掉。 + if (dc.bufferedAmount + sendChunk > HIGH_WATERMARK) { - // eslint-disable-next-line no-console - console.warn("p2p send queue full, backing off then retrying", { - bufferedAmount: dc.bufferedAmount, - readyState: dc.readyState, - bytesSent: this.bytesSent, - }); await waitForBufferLow(dc, LOW_WATERMARK); - dc.send(buf); } - else + const end = Math.min(inBlock + sendChunk, block.byteLength); + // subarray 是零拷贝视图;dc.send 接受 ArrayBufferView,只发该视图字节范围。 + const chunk = block.subarray(inBlock, end); + try { - throw e; + dc.send(chunk); } + catch (e) + { + // 兜底:极端情况下 bufferedAmount 在 await 与 send 之间漂移,仍可能 + // 撞到 OperationError。退一步等 buffer 抽干到 LOW 再重试一次;仍失 + // 败说明通道本身有问题,让外层 .catch 走 markServerFail。 + if (e instanceof DOMException && e.name === "OperationError") + { + // eslint-disable-next-line no-console + console.warn("p2p send queue full, backing off then retrying", { + bufferedAmount: dc.bufferedAmount, + readyState: dc.readyState, + bytesSent: this.bytesSent, + }); + await waitForBufferLow(dc, LOW_WATERMARK); + dc.send(chunk); + } + else + { + throw e; + } + } + const sent = end - inBlock; + inBlock += sent; + offset += sent; + this.bytesSent = offset; + if (!this.firstByteSent) { this.firstByteSent = true; this.setPhase("transferring"); } + this.emitProgress(); } - offset += buf.byteLength; - this.bytesSent = offset; - if (!this.firstByteSent) { this.firstByteSent = true; this.setPhase("transferring"); } - this.emitProgress(); } dc.send(JSON.stringify({ type: "done" })); diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index daa4af8..9bdd44b 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -145,6 +145,8 @@ export const enUS: Partial = { "qr.approve.once.hint": "Signed in for 1 hour, then scan again.", "qr.approve.scopeFull": "This device signs fully into your account for 7 days: it can send and receive files and messages, and manage your account and devices.", "qr.approve.scopeGuest": "Signs in as a limited guest — it can send and receive files and messages, but can't change your account or approve other devices; expires after 1 hour.", + "qr.approve.nativeFull": "This is a native app device — it signs in with full access (trusted, valid for 7 days). Native apps don't support limited guest sessions.", + "qr.approve.trustContinue": "Trust & Continue", "qr.approve.approve": "Approve sign-in", "qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.", "qr.approve.stepUp.button": "Verify and approve", @@ -343,4 +345,68 @@ export const enUS: Partial = { "errors.clipboardUnavailable": "Browser clipboard API unavailable (requires HTTPS + permission)", "errors.clipboardOverflow": "Content too large; exceeds {{max}} byte limit", "errors.clipboardWriteFailed": "Failed to write to local clipboard: {{message}}", + + // ---- iOS client ------------------------------------------------------ + "ios.tab.transfer": "Transfers", + "ios.tab.devices": "Devices", + "ios.tab.settings": "Settings", + "ios.login.generating": "Generating code…", + "ios.login.waiting": "Waiting for approval", + "ios.login.guide": "Scan to approve from another signed-in device", + "ios.login.expired": "Code expired — please try again", + "ios.login.failed": "Sign-in failed — please try again", + "ios.login.refresh": "Refresh QR Code", + "ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.", + "ios.send.pickDevice": "Choose a device", + "ios.transfer.sending": "Sending", + "ios.settings.engine": "Engine", + "ios.settings.status": "Status", + "ios.settings.device": "Device", + "ios.settings.clipboard": "Clipboard", + "ios.settings.clipboardNote": "Upload pushes this device's clipboard to your others; Pull writes the latest cloud clipboard.", + "ios.clipboard.upload": "Upload Clipboard", + "ios.clipboard.pull": "Pull Clipboard", + "ios.clipboard.uploading": "Uploading…", + "ios.clipboard.pulling": "Pulling…", + "ios.clipboard.uploaded": "Uploaded to cloud clipboard", + "ios.clipboard.pulled": "Written to clipboard", + "ios.clipboard.empty": "Clipboard is empty", + "ios.clipboard.failed": "Clipboard sync failed", + "ios.devices.remove": "Remove Device", + "ios.devices.thisDevice": "This device", + "ios.devices.revoked": "Device removed", + "ios.devices.revokeFailed": "Remove failed", + "ios.devices.revokeStepUp": "Re-verify on the web to remove this device", + "ios.engine.disconnected": "Disconnected", + "ios.engine.ready": "Ready", + "ios.transfer.empty": "No transfers yet", + "ios.transfer.incoming": "Receive", + "ios.transfer.outgoing": "Send", + "ios.send.noDevices": "No online devices to send to", + "ios.devices.empty": "No devices yet", + "ios.detail.title": "Transfer Details", + "ios.detail.direction": "Direction", + "ios.detail.peer": "Peer", + "ios.detail.state": "State", + "ios.detail.phase": "Phase", + "ios.detail.channel": "Channel", + "ios.detail.size": "Size", + "ios.detail.progress": "Progress", + "ios.detail.speed": "Speed", + "ios.settings.account": "Account", + "ios.settings.user": "User", + "ios.settings.logout": "Sign Out", + "ios.settings.deviceName": "Device Name", + "ios.settings.deviceNameNote": "Renaming takes effect on next sign-in", + "ios.settings.deviceCount": "Known Devices", + "ios.settings.signaling": "Signaling", + "ios.settings.presenceEvents": "Presence Events", + "ios.settings.reconnecting": "Reconnecting", + "ios.settings.lastLog": "Last Log", + "ios.tab.files": "Files", + "ios.files.title": "Received Files", + "ios.files.empty": "No files received yet", + "ios.files.share": "Share", + "ios.files.done": "Done", + "ios.detail.openFile": "Open File", }; diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index 4e2f309..b9deb80 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -142,6 +142,8 @@ export const zhCN = { "qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。", "qr.approve.scopeFull": "这台设备将完整登录你的账号、7 天内有效:可正常收发文件与消息,并能管理账号与设备。", "qr.approve.scopeGuest": "以受限访客身份登录——可收发文件与消息,但不能更改账号、不能批准其他设备;1 小时后失效。", + "qr.approve.nativeFull": "这是一台原生应用设备,将以完整权限登录(信任此设备、7 天内有效)。原生应用不支持受限访客。", + "qr.approve.trustContinue": "信任并继续", "qr.approve.approve": "批准登录", "qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。", "qr.approve.stepUp.button": "验证并批准", @@ -339,4 +341,68 @@ export const zhCN = { "errors.clipboardUnavailable": "浏览器不支持剪贴板API(需HTTPS与权限)", "errors.clipboardOverflow": "内容过大,超过 {{max}} 字节上限", "errors.clipboardWriteFailed": "写入本机剪贴板失败:{{message}}", + + // ---- iOS 客户端 ------------------------------------------------------ + "ios.tab.transfer": "传输", + "ios.tab.devices": "设备", + "ios.tab.settings": "设置", + "ios.login.generating": "正在生成二维码…", + "ios.login.waiting": "等待扫码批准", + "ios.login.guide": "用另一台已登录的设备扫码批准", + "ios.login.expired": "二维码已失效,请重试", + "ios.login.failed": "登录失败,请重试", + "ios.login.refresh": "刷新二维码", + "ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”", + "ios.send.pickDevice": "选择接收设备", + "ios.transfer.sending": "发送中", + "ios.settings.engine": "引擎", + "ios.settings.status": "状态", + "ios.settings.device": "设备", + "ios.settings.clipboard": "剪贴板", + "ios.settings.clipboardNote": "上传把本机剪贴板推到其他设备;拉取写入最新云剪贴板。", + "ios.clipboard.upload": "上传剪贴板", + "ios.clipboard.pull": "拉取剪贴板", + "ios.clipboard.uploading": "正在上传…", + "ios.clipboard.pulling": "正在拉取…", + "ios.clipboard.uploaded": "已上传到云剪贴板", + "ios.clipboard.pulled": "已写入本机剪贴板", + "ios.clipboard.empty": "剪贴板为空", + "ios.clipboard.failed": "剪贴板同步失败", + "ios.devices.remove": "移除设备", + "ios.devices.thisDevice": "本机", + "ios.devices.revoked": "已移除设备", + "ios.devices.revokeFailed": "移除失败", + "ios.devices.revokeStepUp": "需在网页端重新验证后才能移除", + "ios.engine.disconnected": "未连接", + "ios.engine.ready": "已就绪", + "ios.transfer.empty": "暂无传输", + "ios.transfer.incoming": "接收", + "ios.transfer.outgoing": "发送", + "ios.send.noDevices": "没有在线设备,无法发送", + "ios.devices.empty": "暂无设备", + "ios.detail.title": "传输详情", + "ios.detail.direction": "方向", + "ios.detail.peer": "对端", + "ios.detail.state": "状态", + "ios.detail.phase": "阶段", + "ios.detail.channel": "通道", + "ios.detail.size": "大小", + "ios.detail.progress": "进度", + "ios.detail.speed": "速度", + "ios.settings.account": "账户", + "ios.settings.user": "用户", + "ios.settings.logout": "退出登录", + "ios.settings.deviceName": "设备名称", + "ios.settings.deviceNameNote": "改名将在下次登录后生效", + "ios.settings.deviceCount": "已知设备", + "ios.settings.signaling": "信令连接", + "ios.settings.presenceEvents": "在线事件", + "ios.settings.reconnecting": "重连中", + "ios.settings.lastLog": "最近日志", + "ios.tab.files": "文件", + "ios.files.title": "收到的文件", + "ios.files.empty": "还没有收到文件", + "ios.files.share": "分享", + "ios.files.done": "完成", + "ios.detail.openFile": "打开文件", } as const; diff --git a/web/src/i18n/locales/zh-TW.ts b/web/src/i18n/locales/zh-TW.ts index f53f6c4..9b694f6 100644 --- a/web/src/i18n/locales/zh-TW.ts +++ b/web/src/i18n/locales/zh-TW.ts @@ -146,6 +146,8 @@ export const zhTW: Partial = { "qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。", "qr.approve.scopeFull": "這部裝置將完整登入你的帳號、7 天內有效:可正常收發檔案與訊息,並能管理帳號與裝置。", "qr.approve.scopeGuest": "以受限訪客身分登入——可收發檔案與訊息,但不能變更帳號、不能批准其他裝置;1 小時後失效。", + "qr.approve.nativeFull": "這是一台原生應用裝置,將以完整權限登入(信任此裝置、7 天內有效)。原生應用不支援受限訪客。", + "qr.approve.trustContinue": "信任並繼續", "qr.approve.approve": "批准登入", "qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。", "qr.approve.stepUp.button": "驗證並批准", @@ -343,4 +345,68 @@ export const zhTW: Partial = { "errors.clipboardUnavailable": "瀏覽器不支援剪貼簿API(需HTTPS與權限)", "errors.clipboardOverflow": "內容過大,超過 {{max}} 位元組上限", "errors.clipboardWriteFailed": "寫入本機剪貼簿失敗:{{message}}", + + // ---- iOS 客戶端 ------------------------------------------------------ + "ios.tab.transfer": "傳輸", + "ios.tab.devices": "裝置", + "ios.tab.settings": "設定", + "ios.login.generating": "正在產生 QR 碼…", + "ios.login.waiting": "等待掃碼核准", + "ios.login.guide": "用另一台已登入的裝置掃碼核准", + "ios.login.expired": "QR 碼已失效,請重試", + "ios.login.failed": "登入失敗,請重試", + "ios.login.refresh": "重新整理 QR 碼", + "ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」", + "ios.send.pickDevice": "選擇接收裝置", + "ios.transfer.sending": "傳送中", + "ios.settings.engine": "引擎", + "ios.settings.status": "狀態", + "ios.settings.device": "裝置", + "ios.settings.clipboard": "剪貼簿", + "ios.settings.clipboardNote": "上傳把本機剪貼簿推到其他裝置;拉取寫入最新雲端剪貼簿。", + "ios.clipboard.upload": "上傳剪貼簿", + "ios.clipboard.pull": "拉取剪貼簿", + "ios.clipboard.uploading": "正在上傳…", + "ios.clipboard.pulling": "正在拉取…", + "ios.clipboard.uploaded": "已上傳到雲端剪貼簿", + "ios.clipboard.pulled": "已寫入本機剪貼簿", + "ios.clipboard.empty": "剪貼簿是空的", + "ios.clipboard.failed": "剪貼簿同步失敗", + "ios.devices.remove": "移除裝置", + "ios.devices.thisDevice": "本機", + "ios.devices.revoked": "已移除裝置", + "ios.devices.revokeFailed": "移除失敗", + "ios.devices.revokeStepUp": "需在網頁端重新驗證後才能移除", + "ios.engine.disconnected": "未連線", + "ios.engine.ready": "已就緒", + "ios.transfer.empty": "尚無傳輸", + "ios.transfer.incoming": "接收", + "ios.transfer.outgoing": "傳送", + "ios.send.noDevices": "沒有上線裝置,無法傳送", + "ios.devices.empty": "尚無裝置", + "ios.detail.title": "傳輸詳情", + "ios.detail.direction": "方向", + "ios.detail.peer": "對端", + "ios.detail.state": "狀態", + "ios.detail.phase": "階段", + "ios.detail.channel": "通道", + "ios.detail.size": "大小", + "ios.detail.progress": "進度", + "ios.detail.speed": "速度", + "ios.settings.account": "帳戶", + "ios.settings.user": "使用者", + "ios.settings.logout": "登出", + "ios.settings.deviceName": "裝置名稱", + "ios.settings.deviceNameNote": "改名將在下次登入後生效", + "ios.settings.deviceCount": "已知裝置", + "ios.settings.signaling": "信令連線", + "ios.settings.presenceEvents": "上線事件", + "ios.settings.reconnecting": "重新連線中", + "ios.settings.lastLog": "最近日誌", + "ios.tab.files": "檔案", + "ios.files.title": "收到的檔案", + "ios.files.empty": "尚未收到檔案", + "ios.files.share": "分享", + "ios.files.done": "完成", + "ios.detail.openFile": "開啟檔案", }; diff --git a/web/src/net/api.ts b/web/src/net/api.ts index 53e6f3a..95a69bd 100644 --- a/web/src/net/api.ts +++ b/web/src/net/api.ts @@ -79,11 +79,13 @@ async function doFetch(input: RequestInfo, init?: RequestInit): Promise void; +} + +// 原生注册的引擎消息处理器。其在场即判定为 iOS 无头壳。 +function handler(): WebKitMessageHandler | null +{ + const wk = (window as unknown as { + webkit?: { messageHandlers?: { cdropEngine?: WebKitMessageHandler } }; + }).webkit; + return wk?.messageHandlers?.cdropEngine ?? null; +} + +// isIOSShell 仅要求消息处理器在场——原生注入它即代表无头壳就绪。浏览器 / 桌面里 +// 不存在 window.webkit.messageHandlers.cdropEngine,故恒为 false。 +export function isIOSShell(): boolean +{ + return typeof window !== "undefined" && handler() !== null; +} + +// ── 请求 / 响应关联(callNative) ────────────────────────────────────────── + +interface Pending +{ + resolve: (value: unknown) => void; + reject: (err: Error) => void; +} + +const pending = new Map(); +let nextRequestId = 1; + +// 安装全局 resolver(幂等):原生完成一次调用后经 evaluateJavaScript 调它回交结果。 +function installResolver(): void +{ + const w = window as unknown as { + __cdropEngineResolve?: (id: number, ok: boolean, value: unknown) => void; + }; + if (w.__cdropEngineResolve) { return; } + w.__cdropEngineResolve = (id, ok, value) => + { + const p = pending.get(id); + if (!p) { return; } + pending.delete(id); + if (ok) { p.resolve(value); } + else { p.reject(new Error(typeof value === "string" ? value : "native error")); } + }; +} + +// callNative 投递一次 { id, method, payload } 给原生,返回待原生回调 resolve 的 +// Promise。桥缺失(非 iOS 壳)直接 reject——调用点应先以 isIOSShell() 守卫。 +function callNative(method: string, payload?: unknown): Promise +{ + const h = handler(); + if (!h) { return Promise.reject(new Error("ios bridge unavailable")); } + installResolver(); + const id = nextRequestId; + nextRequestId += 1; + return new Promise((resolve, reject) => + { + pending.set(id, { resolve: resolve as (value: unknown) => void, reject }); + h.postMessage({ id, method, payload }); + }); +} + +// notifyNative 单向投递一条通知给原生(进度 / 状态 / 就绪 / 错误等,无需回执)。原生 +// 据消息形状区分:含 id 为 callNative 请求、含 notify 为单向通知。非 iOS 壳为 no-op, +// 故引擎可无条件调用、无需在每个发射点加 isIOSShell() 分支。 +export function notifyNative(name: string, payload?: unknown): void +{ + const h = handler(); + if (!h) { return; } + h.postMessage({ notify: name, payload }); +} + +// ── 原生 → JS 事件 ───────────────────────────────────────────────────────── + +type EventHandler = (payload: unknown) => void; + +const eventHandlers = new Map>(); + +// 安装全局事件入口(幂等):原生用 evaluateJavaScript 调它派发事件。 +function installEventSink(): void +{ + const w = window as unknown as { + __cdropEngineEvent?: (name: string, payload: unknown) => void; + }; + if (w.__cdropEngineEvent) { return; } + w.__cdropEngineEvent = (name, payload) => + { + const set = eventHandlers.get(name); + if (!set) { return; } + for (const fn of set) { fn(payload); } + }; +} + +// onNativeEvent 订阅一类原生事件,返回取消订阅函数。非 iOS 壳里仍可调用(只是原生 +// 永不派发),保持调用点无需分支。 +export function onNativeEvent(name: string, fn: EventHandler): () => void +{ + installEventSink(); + let set = eventHandlers.get(name); + if (!set) + { + set = new Set(); + eventHandlers.set(name, set); + } + set.add(fn); + return () => { set?.delete(fn); }; +} + +// ── 接收落盘桥(与桌面 desktop.ts 同形,落 iOS 沙盒) ────────────────────── + +// bytesToBase64 分块走 btoa,避免一次 String.fromCharCode(...大数组) 触发调用栈溢出。 +// 与 desktop.ts 同实现;两边各持一份以保持桥模块互不依赖。 +function bytesToBase64(bytes: Uint8Array): string +{ + let binary = ""; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) + { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + +// saveIncomingFileIOS:整文件(接收端小文件路径)经 base64 过桥交原生写入沙盒下载 +// 目录,返回最终绝对路径。桥缺失或写盘失败抛错,由调用方回退。 +export async function saveIncomingFileIOS(name: string, blob: Blob): Promise +{ + const buf = await blob.arrayBuffer(); + return callNative("saveDownload", { name, data: bytesToBase64(new Uint8Array(buf)) }); +} + +// 流式落盘封装:接收端混合 sink 越过内存上限后,把溢出批次交原生写临时文件,finalize +// 时改名落定。与桌面 Begin/Append/Finalize/Abort 同语义(见 incomingSink.ts)。 +export async function beginIncomingDownloadIOS(sessionId: string): Promise +{ + await callNative("beginDownload", { sessionId }); +} + +export async function appendIncomingDownloadIOS(sessionId: string, bytes: Uint8Array): Promise +{ + await callNative("appendDownload", { sessionId, data: bytesToBase64(bytes) }); +} + +export async function finalizeIncomingDownloadIOS(sessionId: string, name: string): Promise +{ + return callNative("finalizeDownload", { sessionId, name }); +} + +export async function abortIncomingDownloadIOS(sessionId: string): Promise +{ + if (!handler()) { return; } + try { await callNative("abortDownload", { sessionId }); } + catch { /* 取消 / 失败路径,吞掉 */ } +} diff --git a/web/src/net/qr.ts b/web/src/net/qr.ts index 0c972be..08e5c0d 100644 --- a/web/src/net/qr.ts +++ b/web/src/net/qr.ts @@ -67,8 +67,10 @@ export interface QrApprovedUser export interface QrStatus { status: QrStatusPhase; - // status === "approved" 时一并带回: + // status === "approved" 时一并带回 broker 令牌对 + 设备身份: accessToken?: string; + refreshToken?: string; + deviceId?: string; expiresIn?: number; user?: QrApprovedUser; deviceName?: string; @@ -78,6 +80,8 @@ interface QrStatusRaw { status: QrStatusPhase; access_token?: string; + refresh_token?: string; + device_id?: string; expires_in?: number; user?: QrApprovedUser; device_name?: string; @@ -102,6 +106,8 @@ async function qrStatusOnce( return { status: data.status, accessToken: data.access_token, + refreshToken: data.refresh_token, + deviceId: data.device_id, expiresIn: data.expires_in, user: data.user, deviceName: data.device_name, @@ -174,9 +180,6 @@ export interface QrRequestInfo requestIp: string; expiresAt: number; status: QrStatusPhase; - // step_up 开启时为 true:批准前必须做一次新鲜的 prompt=login 再认证,把这次 - // 拿到的授权码 + verifier 交给 approve 就地核验(见 features/qr/stepUp.ts)。 - stepUp: boolean; } interface QrRequestInfoRaw @@ -186,7 +189,6 @@ interface QrRequestInfoRaw request_ip: string; expires_at: number; status: QrStatusPhase; - step_up?: boolean; } // qrRequest 取待批准设备的信息,批准页据此渲染安全确认。需完整登录(apiFetch 带 @@ -202,35 +204,17 @@ export async function qrRequest(requestId: string, code: string): Promise { const body: Record = { @@ -239,11 +223,6 @@ export async function qrApprove( scope, persist, }; - if (stepUpCode && stepUpVerifier) - { - body.step_up_code = stepUpCode; - body.step_up_verifier = stepUpVerifier; - } const r = await apiFetch("/api/auth/qr/approve", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -252,10 +231,6 @@ export async function qrApprove( if (!r.ok) { const text = await r.text().catch(() => r.statusText); - if (r.status === 403 && /step_up_required/.test(text)) - { - throw new StepUpRequiredError(); - } throw new Error(`qr approve ${r.status}: ${text}`); } } diff --git a/web/src/net/sessions.ts b/web/src/net/sessions.ts index 91946c0..792ea3a 100644 --- a/web/src/net/sessions.ts +++ b/web/src/net/sessions.ts @@ -33,11 +33,11 @@ export async function fetchMeScope(): Promise // 会话「种类」:oidc / self / guest 为网页 / 扫码会话;macos / windows / linux / ios // 为原生客户端(桌面 / 移动)——它们用 IdP 令牌、不入 web_sessions,由设备登记表呈现。 export type SessionKind = - | "oidc" | "self" | "guest" + | "browser" | "macos" | "windows" | "linux" | "ios"; const KNOWN_KINDS: readonly SessionKind[] = [ - "oidc", "self", "guest", "macos", "windows", "linux", "ios", + "browser", "macos", "windows", "linux", "ios", ]; export interface AuthSession @@ -50,25 +50,21 @@ export interface AuthSession current: boolean; // online=true:该设备当前在线(有活跃 SSE 连接)。用于列表排序与在线指示。 online: boolean; - // native=true:原生客户端(无 web_session),登出走设备登记端点而非会话端点。 - native: boolean; createdAt: number; lastUsedAt: number; - expiresAt: number; } interface AuthSessionRaw { id: string; + device_id?: string; device_name?: string; kind?: string; scope?: string; current?: boolean; online?: boolean; - native?: boolean; created_at?: number; last_used_at?: number; - expires_at?: number; } interface SessionsResp @@ -78,7 +74,7 @@ interface SessionsResp function normalizeKind(kind: string | undefined): SessionKind { - return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "oidc"; + return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "browser"; } // listSessions 拉取当前账号下的全部登录会话(含本机),每条带 online(当前是否在线)。 @@ -96,61 +92,19 @@ export async function listSessions(): Promise scope: r.scope === "guest" ? "guest" : "full", current: r.current === true, online: r.online === true, - native: r.native === true, createdAt: r.created_at ?? 0, lastUsedAt: r.last_used_at ?? 0, - expiresAt: r.expires_at ?? 0, })); } -// StepUpRequiredError:DELETE /api/auth/sessions/{id} 因再认证缺失 / 过期被后端拒 -// (403 {error:"step_up_required"})。调用方据此发起一次 step-up 再认证而非当成 -// 普通错误。 -export class StepUpRequiredError extends Error -{ - constructor() - { - super("step_up_required"); - this.name = "StepUpRequiredError"; - } -} - -// revokeSession 真正吊销一条登录会话。网页 / 扫码会话走 /auth/sessions/{id}(删会话 -// 行 + 连带删设备);原生客户端(id 形如 "device:设备名")无 web_session,改走 -// /devices/{name} 按设备名注销。两者均:成功 204;后端要求再认证时抛 -// StepUpRequiredError(403 step_up_required),其余非 2xx 抛普通错误。 -const NATIVE_ID_PREFIX = "device:"; - +// revokeSession 吊销一条会话(= 一台设备):DELETE /api/auth/sessions/{id}(id=device_id)。 +// 后端连带 broker 吊销该设备的会话 + 删本地设备行。成功 204。step-up 二次认证已移除。 export async function revokeSession(id: string): Promise { - const path = id.startsWith(NATIVE_ID_PREFIX) - ? `/api/devices/${encodeURIComponent(id.slice(NATIVE_ID_PREFIX.length))}` - : `/api/auth/sessions/${encodeURIComponent(id)}`; - const r = await apiFetch(path, { method: "DELETE" }); + const r = await apiFetch(`/api/auth/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }); if (!r.ok) { const text = await r.text().catch(() => r.statusText); - if (r.status === 403 && /step_up_required/.test(text)) - { - throw new StepUpRequiredError(); - } throw new Error(`revoke session ${r.status}: ${text}`); } } - -// postStepUp 把一次新鲜 prompt=login 再认证拿到的 {code, verifier} 交给后端就地 -// 核验,成功后服务端在本会话记下 stepped_up_at(5 分钟窗口内复用,不再反复要求)。 -// 成功 204;step-up 未开后端亦 204(视作通过);失败 403。 -export async function postStepUp(code: string, verifier: string): Promise -{ - const r = await apiFetch("/api/auth/stepup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code, verifier }), - }); - if (!r.ok) - { - const text = await r.text().catch(() => r.statusText); - throw new Error(`step-up ${r.status}: ${text}`); - } -} diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 8ab44e5..527dd1e 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -339,8 +339,7 @@ function isAuthRoute(pathname: string): boolean || pathname === "/setup" || pathname === "/link" || pathname === "/link/new" - || pathname === "/link/scan" - || pathname.startsWith("/oauth/"); + || pathname === "/link/scan"; } function initial(name: string): string diff --git a/web/src/routes/link.tsx b/web/src/routes/link.tsx index 3c8bd78..f471319 100644 --- a/web/src/routes/link.tsx +++ b/web/src/routes/link.tsx @@ -2,12 +2,11 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { AlertTriangle, Check, Clock, LogIn, ShieldCheck, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { AuthShell } from "../features/auth/AuthShell"; -import { loginProd, startStepUpReauth } from "../features/auth/auth"; +import { loginRedirect } from "../features/auth/auth"; import { stashLinkReturn } from "../features/qr/returnTo"; -import { consumeStepUpCode, type StepUpStash } from "../features/qr/stepUp"; import { isDesktop, startDesktopLogin } from "../net/desktop"; import { - qrApprove, qrDeny, qrRequest, StepUpRequiredError, + qrApprove, qrDeny, qrRequest, type QrPersist, type QrRequestInfo, } from "../net/qr"; import { useAppStore } from "../store"; @@ -44,12 +43,8 @@ function LinkApprovePage() const [ submitting, setSubmitting ] = useState(false); const [ outcome, setOutcome ] = useState(null); const [ actionError, setActionError ] = useState(null); - // step-up:true=后端要求批准前先做新鲜 prompt=login 再认证(provider 2FA 即此处过)。 - const [ stepUpRequired, setStepUpRequired ] = useState(false); - // 再认证往返回来后由后端二次拒(403 step_up_required),文案提示并允许重试。 - const [ stepUpRejected, setStepUpRejected ] = useState(false); - // 未登录:暂存当前 URL 后走标准登录,登录后由首页 beforeLoad 弹回这里。 + // 未登录:暂存当前 URL 后走 broker 全局 SSO 登录,登录后由首页 beforeLoad 弹回这里。 const handleSignIn = useCallback(async () => { stashLinkReturn(window.location.pathname + window.location.search); @@ -60,12 +55,12 @@ function LinkApprovePage() await startDesktopLogin(); navigate({ to: "/" }); } - else { await loginProd(); } + else { loginRedirect(); } } catch (e) { setLoadError(e instanceof Error ? e.message : String(e)); } }, [ navigate ]); - // 已登录且参数齐全:拉取待批准设备信息(含 step_up 标记)。 + // 已登录且参数齐全:拉取待批准设备信息。 useEffect(() => { if (!user || !requestId || !code) { return; } @@ -75,7 +70,6 @@ function LinkApprovePage() { if (cancelled) { return; } setInfo(data); - setStepUpRequired(data.stepUp); if (data.status === "expired") { setOutcome("expired"); } else if (data.status === "denied") { setOutcome("denied"); } else if (data.status === "approved") { setOutcome("approved"); } @@ -85,69 +79,40 @@ function LinkApprovePage() return () => { cancelled = true; }; }, [ user, requestId, code ]); - // doApprove 执行批准;stash 非空时把 step-up 再认证拿到的 code+verifier 一并交给 - // 后端就地核验。403 step_up_required(StepUpRequiredError)单独走「需重新验证」态, - // 不当成普通错误。 - const doApprove = useCallback(async (stash?: StepUpStash) => + // doApprove 执行批准。step-up 二次认证已移除(full 档即可信,路由层守门)。 + const doApprove = useCallback(async () => { if (!requestId || !code) { return; } setSubmitting(true); setActionError(null); - setStepUpRejected(false); try { // persist=「信任此设备」→ 完整会话(scope=full);once=「仅此次」→ 受限访客 - // (scope=guest)。后端按 scope 决定授予的会话级别,二者与 persist 时长一一对应。 + // (scope=guest)。后端按 scope 经 broker 委托签发对应 tier 的会话。 const scope = persist === "persist" ? "full" : "guest"; - await qrApprove(requestId, code, scope, persist, stash?.code, stash?.verifier); + await qrApprove(requestId, code, scope, persist); setOutcome("approved"); } catch (e) { - if (e instanceof StepUpRequiredError) { setStepUpRejected(true); } - else { setActionError(e instanceof Error ? e.message : String(e)); } + setActionError(e instanceof Error ? e.message : String(e)); } finally { setSubmitting(false); } }, [ requestId, code, persist ]); - // 再认证往返回来:检测到暂存的 step-up {code, verifier} → 自动调 approve 完成批准 - // → 用完即清(consumeStepUpCode 取出即清)。仅在已登录、设备信息已就绪后跑一次。 + // 原生客户端(非浏览器:iOS / 桌面)只接受完整权限会话——用户原则:原生 App 不允许受限 + // 访客(仅 Web / PWA 可 guest)。故批准端对原生请求强制信任时长=persist(scope=full)、 + // 隐藏「仅此次」选项,只给「信任并继续 / 拒绝」。后端 handleQRApprove 亦兜底强制。 + const requiresFull = info != null && info.deviceType !== "browser"; useEffect(() => { - if (!user || !info || outcome) { return; } - const stash = consumeStepUpCode(); - if (!stash) { return; } - void doApprove(stash); - }, [ user, info, outcome, doApprove ]); + if (requiresFull) { setPersist("persist"); } + }, [ requiresFull ]); - // handleApprove:批准按钮。step-up 要求且尚无再认证凭证时,先把用户带去做一次 - // 新鲜 prompt=login 再认证(往返回来后由上面的 effect 自动接力 approve);否则 - // 维持原有直接批准行为不变。 + // handleApprove:批准按钮,直接提交(step-up 已移除)。 const handleApprove = async () => { if (!requestId || !code) { return; } - if (stepUpRequired) - { - setSubmitting(true); - setActionError(null); - setStepUpRejected(false); - try - { - // 把当前「信任时长」选择编进 returnTo:step-up 整页跳转回来后页面重载、 - // React state 复位,必须从 URL 恢复 persist,否则会回落默认、令本应受限 - // 的设备被建成完整会话(#2 根因)。 - const back = new URLSearchParams(window.location.search); - back.set("p", persist); - await startStepUpReauth(window.location.pathname + "?" + back.toString()); - } - catch (e) - { - setActionError(e instanceof Error ? e.message : String(e)); - setSubmitting(false); - } - // 成功则整页跳转去 provider,本组件随之卸载,不再 setSubmitting。 - return; - } await doApprove(); }; @@ -241,46 +206,37 @@ function LinkApprovePage() - {/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。 */} -
- - -
+ {/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。 + 原生客户端不给选择——强制完整登录,故隐藏分段,仅留下方完整权限说明。 */} + {!requiresFull && ( +
+ + +
+ )} - {/* 权限范围说明:随选中项切换——信任=完整登录、仅此次=受限访客,明确告知 - 这台设备将以什么身份登录(非装饰)。 */} + {/* 权限范围说明:原生客户端固定完整登录;浏览器随选中项切换(信任=完整、仅此次= + 受限访客),明确告知这台设备将以什么身份登录(非装饰)。 */} }> - {persist === "persist" - ? t("qr.approve.scopeFull") - : t("qr.approve.scopeGuest")} + {requiresFull + ? t("qr.approve.nativeFull") + : persist === "persist" + ? t("qr.approve.scopeFull") + : t("qr.approve.scopeGuest")} - {/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */} - {stepUpRequired && !stepUpRejected && ( - }> - {t("qr.approve.stepUp.notice")} - - )} - - {/* 再认证回来仍被后端拒:给方向(凭证过期 / 验证未通过)并允许重试。 */} - {stepUpRejected && ( - }> - {t("qr.approve.stepUp.rejected")} - - )} - {actionError && ( }>{actionError} )} @@ -291,13 +247,11 @@ function LinkApprovePage() size="lg" block loading={submitting} - leftIcon={ - stepUpRequired ? : - } + leftIcon={} onClick={() => { void handleApprove(); }} > - {stepUpRequired - ? t("qr.approve.stepUp.button") + {requiresFull + ? t("qr.approve.trustContinue") : t("qr.approve.approve")} diff --git a/web/src/store/helpers.ts b/web/src/store/helpers.ts index 62fff8e..e7a1550 100644 --- a/web/src/store/helpers.ts +++ b/web/src/store/helpers.ts @@ -22,6 +22,11 @@ export function normalizeTerminal(cur: TransferRecord, finalState: string): Tran // ---- storage keys & readers ---- export const ACCESS_TOKEN_KEY = "cdrop.access_token"; +// refresh_token now lives in JS too (sessionStorage): after the Auth Broker migration +// a QR-paired device holds the broker's access + refresh pair directly and refreshes +// via /api/auth/refresh. A global-SSO browser holds neither (the broker domain cookie +// carries it). Desktop keeps its refresh in the Go process, never injected. +export const REFRESH_TOKEN_KEY = "cdrop.refresh_token"; export const USER_KEY = "cdrop.user"; export const SESSION_SCOPE_KEY = "cdrop.session_scope"; export const SELF_DEVICE_KEY = "cdrop.self_device"; @@ -37,6 +42,10 @@ export const THEME_KEY = "cdrop.theme"; interface InjectedSession { access_token: string; + // refresh_token 现随注入会话一并下发(iOS 引擎自刷需要它):引擎用它经 + // /api/auth/refresh 续期,并在 broker 轮换后回报原生更新 Keychain。桌面注入仍不带 + // (桌面 refresh 在 Go 进程内)。 + refresh_token?: string; user: { id: string; name: string; avatar?: string }; } @@ -94,6 +103,17 @@ export function readSessionAccess(): string | null return window.sessionStorage.getItem(ACCESS_TOKEN_KEY); } +// readSessionRefresh restores the broker refresh token across a same-tab reload. +// Desktop injection never carries it (refresh lives in the Go process); a global-SSO +// browser has none. Null when absent. +export function readSessionRefresh(): string | null +{ + const inj = injectedSession(); + if (inj?.refresh_token) { return inj.refresh_token; } + if (typeof window === "undefined") { return null; } + return window.sessionStorage.getItem(REFRESH_TOKEN_KEY); +} + // user.id / user.name 不是机密(公开显示在 UI、JWT 里可解码),随 access_token // 一并持久化,才能让刷新 / 重启后路由守卫不再误跳 /login。 export function readSessionUser(): User | null diff --git a/web/src/store/slices/auth.ts b/web/src/store/slices/auth.ts index 7e98d93..62e172a 100644 --- a/web/src/store/slices/auth.ts +++ b/web/src/store/slices/auth.ts @@ -2,16 +2,18 @@ import type { StateCreator } from "zustand"; import type { AppState, AuthMode, SessionScope } from "../types"; import { ACCESS_TOKEN_KEY, + REFRESH_TOKEN_KEY, SESSION_SCOPE_KEY, USER_KEY, readSessionAccess, + readSessionRefresh, readSessionScope, readSessionUser, } from "../helpers"; export type AuthSlice = Pick< AppState, - "authMode" | "accessToken" | "user" | "sessionScope" + "authMode" | "accessToken" | "refreshToken" | "user" | "sessionScope" | "setAuth" | "setSessionScope" | "clearAuth" >; @@ -25,20 +27,33 @@ export const createAuthSlice: StateCreator = (set) ({ authMode: initialAuthMode, accessToken: readSessionAccess(), + refreshToken: readSessionRefresh(), user: readSessionUser(), sessionScope: readSessionScope(), + // setAuth records the session. accessToken may be null (global-SSO browser — the + // broker cookie carries identity). refreshToken is optional: omit it to leave a + // previously stored one untouched (e.g. a /api/me bootstrap that only confirms the + // user); pass null to clear it, a string to replace it. setAuth: (a) => { if (typeof window !== "undefined") { - window.sessionStorage.setItem(ACCESS_TOKEN_KEY, a.accessToken); + if (a.accessToken) { window.sessionStorage.setItem(ACCESS_TOKEN_KEY, a.accessToken); } + else { window.sessionStorage.removeItem(ACCESS_TOKEN_KEY); } + if (a.refreshToken !== undefined) + { + if (a.refreshToken) { window.sessionStorage.setItem(REFRESH_TOKEN_KEY, a.refreshToken); } + else { window.sessionStorage.removeItem(REFRESH_TOKEN_KEY); } + } window.sessionStorage.setItem(USER_KEY, JSON.stringify(a.user)); } - set({ + set((s) => + ({ accessToken: a.accessToken, + refreshToken: a.refreshToken !== undefined ? a.refreshToken : s.refreshToken, user: a.user, - }); + })); }, // setSessionScope 由 /api/me 解析结果驱动(登录成功 / 开机水合后调用)。 @@ -58,10 +73,11 @@ export const createAuthSlice: StateCreator = (set) if (typeof window !== "undefined") { window.sessionStorage.removeItem(ACCESS_TOKEN_KEY); + window.sessionStorage.removeItem(REFRESH_TOKEN_KEY); window.sessionStorage.removeItem(USER_KEY); window.sessionStorage.removeItem(SESSION_SCOPE_KEY); } // scope 回 "full":登出后下一个登录者默认完整态,再由其自己的 /api/me 校正。 - set({ accessToken: null, user: null, sessionScope: "full" }); + set({ accessToken: null, refreshToken: null, user: null, sessionScope: "full" }); }, }); diff --git a/web/src/store/types.ts b/web/src/store/types.ts index bb88642..1fa2f0a 100644 --- a/web/src/store/types.ts +++ b/web/src/store/types.ts @@ -123,13 +123,17 @@ export interface AppState { // ---- auth slice ---- authMode: AuthMode; + // accessToken is null for a global-SSO browser (the broker domain cookie carries + // identity; requests go without a Bearer). A QR-paired device holds the broker + // access token here and the refresh token alongside. accessToken: string | null; + refreshToken: string | null; user: User | null; // 本会话权限级别(/api/me 的 scope)。默认 "full";登录成功 / 开机水合后据 // /api/me 校正为 "guest" 时,UI 隐藏「扫码登录新设备」「移除设备」「登出他人」 // 等账号管理入口,并展示「受限访客」标识。 sessionScope: SessionScope; - setAuth: (a: { accessToken: string; user: User }) => void; + setAuth: (a: { accessToken: string | null; refreshToken?: string | null; user: User }) => void; setSessionScope: (scope: SessionScope) => void; clearAuth: () => void; diff --git a/web/vite.config.ts b/web/vite.config.ts index 63b9ffe..6e0934e 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -17,6 +17,17 @@ export default defineConfig({ ), }, }, + build: { + // 多入口:main = 完整 React 应用(index.html);engine = iOS 无头传输引擎 + // (engine.html,不含 React / UI,见 src/engine/main.ts、ios/PLAN.md arch A)。 + // 两个 bundle 互不污染——engine 不把 React / 路由拖进 main,main 也不含引擎入口。 + rollupOptions: { + input: { + main: fileURLToPath(new URL("./index.html", import.meta.url)), + engine: fileURLToPath(new URL("./engine.html", import.meta.url)), + }, + }, + }, server: { proxy: { "/api": "http://localhost:8080",