鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移
后端(委托 Auth Broker,路径 A): - 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest() - internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等 统一会话模型“委派设备会话”(Delegated Device Sessions): - 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表 - 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备 - 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定 - 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行 Web 前端: - 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh - 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环 - 去 OIDC PKCE / step-up(删 oauth.callback / stepUp) 桌面客户端(Wails): - loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置 iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥): - 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库) - 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId 实时 / 健壮性: - presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线) - Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热) 配置 / 删旧栈: - config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效) - 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传) - 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
+25
-26
@@ -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)。
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+43
-29
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-16
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+69
-68
@@ -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) ---
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,64 @@
|
||||
# 并入你的主 Caddyfile(站点 block)。换上你的域名与服务名。
|
||||
# cdrop(drop.<your-domain>)的 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-20
@@ -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: <just vapid-keygen 输出>
|
||||
# CDROP_VAPID_PRIVATE_KEY: <just vapid-keygen 输出>
|
||||
|
||||
# ---- 可选:扫码登录(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: <dash.cloudflare.com → Realtime → TURN App>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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:<app>:<tier>); 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
|
||||
}
|
||||
+70
-72
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+41
-18
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+116
-43
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 清理。
|
||||
|
||||
+8
-41
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 = ?;
|
||||
@@ -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 < ?;
|
||||
|
||||
@@ -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 = ?;
|
||||
@@ -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 = ?;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+48
-19
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 + "/"
|
||||
}
|
||||
+109
-177
@@ -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
|
||||
}
|
||||
|
||||
+330
-354
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+92
-100
@@ -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,
|
||||
|
||||
+25
-215
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-169
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+11
-9
@@ -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
|
||||
}
|
||||
|
||||
+49
-32
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+40
-34
@@ -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>". 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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+77
-340
@@ -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":
|
||||
|
||||
+115
-540
@@ -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=<desktop client_id>; 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# CDrop.xcodeproj 由 xcodegen 从 project.yml 生成——不提交,用 `xcodegen generate` 重建。
|
||||
CDrop.xcodeproj/
|
||||
|
||||
# Xcode 构建产物
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
.DS_Store
|
||||
@@ -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 + 账号才能跑。
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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://<id>
|
||||
// 回取字节(下方 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://<id> 引用 + 目标设备交给引擎发起传输。
|
||||
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://<id> 引用。文件选择器给的 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://<id> 喂给引擎(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"] ?? ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
// 无需更新。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
// i18n:读 app bundle 内的 <locale>.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)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh-Hans</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Commilitia Drop</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>zh-Hans</string>
|
||||
<string>zh-Hant</string>
|
||||
<string>en</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_cdrop._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Commilitia Drop 使用相机扫描二维码登录新设备。</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "打开文件"
|
||||
}
|
||||
@@ -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": "開啟檔案"
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
@@ -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 摘要) |
|
||||
+198
-69
@@ -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) |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- cdrop 无头传输引擎页(arch A,见 ios/PLAN.md §2)。无 UI / 字体 / 图标——只承载
|
||||
传输引擎。仅由原生 iOS 壳的离屏 WKWebView 从安全源(https)加载;原生须在本
|
||||
模块执行前经 WKUserScript 注入 window.__CDROP_BOOT__(session / device_name /
|
||||
api_base / device_type:"ios"),见 src/engine/main.ts。 -->
|
||||
<title>cdrop engine</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/src/engine/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
@@ -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<typeof setTimeout> | 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<typeof useAppStore.getState>): 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<void>
|
||||
{
|
||||
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<void>
|
||||
{
|
||||
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();
|
||||
+258
-304
@@ -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<AuthConfig>
|
||||
{
|
||||
if (cachedConfig) { return cachedConfig; }
|
||||
const r = await fetch("/api/auth/config");
|
||||
if (!r.ok) { throw new Error(`auth config ${r.status}`); }
|
||||
cachedConfig = await r.json() as AuthConfig;
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier";
|
||||
const OAUTH_STATE_KEY = "cdrop.oauth_state";
|
||||
|
||||
// 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<void>
|
||||
{
|
||||
const cfg = await fetchAuthConfig();
|
||||
if (cfg.auth_mode !== "prod")
|
||||
{
|
||||
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
|
||||
}
|
||||
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
|
||||
{
|
||||
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
|
||||
}
|
||||
|
||||
const verifier = generateRandomBase64url(32);
|
||||
const challenge = await sha256Base64url(verifier);
|
||||
const state = generateRandomBase64url(16);
|
||||
|
||||
localStorage.setItem(PKCE_VERIFIER_KEY, verifier);
|
||||
localStorage.setItem(OAUTH_STATE_KEY, state);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: cfg.client_id,
|
||||
redirect_uri: cfg.redirect_uri,
|
||||
response_type: "code",
|
||||
scope: cfg.scopes || "openid profile email",
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
window.location.href = `${cfg.authorize_url}?${params}`;
|
||||
}
|
||||
|
||||
// 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<void>
|
||||
{
|
||||
const cfg = await fetchAuthConfig();
|
||||
if (cfg.auth_mode !== "prod")
|
||||
{
|
||||
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
|
||||
}
|
||||
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
|
||||
{
|
||||
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
|
||||
}
|
||||
|
||||
const verifier = generateRandomBase64url(32);
|
||||
const challenge = await sha256Base64url(verifier);
|
||||
const state = generateRandomBase64url(16);
|
||||
|
||||
// 先把「待办」落定(含 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<void>
|
||||
{
|
||||
const cur = useAppStore.getState();
|
||||
if (cur.user && cur.accessToken)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedState = localStorage.getItem(OAUTH_STATE_KEY);
|
||||
if (!expectedState || state !== expectedState)
|
||||
{
|
||||
throw new Error("OAuth state mismatch (possible CSRF or stale tab)");
|
||||
}
|
||||
const verifier = localStorage.getItem(PKCE_VERIFIER_KEY);
|
||||
if (!verifier)
|
||||
{
|
||||
throw new Error("PKCE verifier missing — restart the login flow");
|
||||
}
|
||||
|
||||
const r = await fetch("/api/auth/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, code_verifier: verifier }),
|
||||
});
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
throw new Error(`token exchange ${r.status}: ${text}`);
|
||||
}
|
||||
const data = await r.json() as ExchangeResp;
|
||||
|
||||
localStorage.removeItem(PKCE_VERIFIER_KEY);
|
||||
localStorage.removeItem(OAUTH_STATE_KEY);
|
||||
|
||||
useAppStore.getState().setAuth({
|
||||
accessToken: data.access_token,
|
||||
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<CookieRefreshResult>
|
||||
// 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<BrokerRefreshResult>
|
||||
{
|
||||
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<RefreshOutcome>
|
||||
{
|
||||
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<RefreshOutcome>
|
||||
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<void>
|
||||
{
|
||||
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<MeResp | null>
|
||||
{
|
||||
const token = useAppStore.getState().accessToken;
|
||||
const headers: Record<string, string> = {};
|
||||
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<void>
|
||||
{
|
||||
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<void>
|
||||
{
|
||||
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<void>
|
||||
{
|
||||
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<void>
|
||||
{
|
||||
// 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<void>
|
||||
{
|
||||
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<string>
|
||||
{
|
||||
const buf = new TextEncoder().encode(input);
|
||||
const hash = await crypto.subtle.digest("SHA-256", buf);
|
||||
return base64urlEncode(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
function base64urlEncode(bytes: Uint8Array): string
|
||||
{
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) { s += String.fromCharCode(bytes[i]); }
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
/* no-op: see comment above */
|
||||
}
|
||||
|
||||
@@ -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<StepUpPending>(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<StepUpStash>(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<T>(raw: string): T | null
|
||||
{
|
||||
try { return JSON.parse(raw) as T; }
|
||||
catch { return null; }
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
// 桌面混合 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<void>;
|
||||
append: (sessionId: string, bytes: Uint8Array) => Promise<void>;
|
||||
finalize: (sessionId: string, name: string) => Promise<string>;
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面混合有界内存槽。小文件(暂存 < 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<void> =>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -145,6 +145,8 @@ export const enUS: Partial<TranslationDict> = {
|
||||
"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<TranslationDict> = {
|
||||
"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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -146,6 +146,8 @@ export const zhTW: Partial<TranslationDict> = {
|
||||
"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<TranslationDict> = {
|
||||
"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": "開啟檔案",
|
||||
};
|
||||
|
||||
+5
-3
@@ -79,11 +79,13 @@ async function doFetch(input: RequestInfo, init?: RequestInit): Promise<Response
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!state.accessToken)
|
||||
// A QR-paired device sends its broker access token as Bearer. A global-SSO
|
||||
// browser has none — the broker's domain cookie carries identity, so we send
|
||||
// no Authorization header and let the edge inject X-Auth from that cookie.
|
||||
if (state.accessToken)
|
||||
{
|
||||
throw new Error(t("errors.noAccessToken"));
|
||||
headers.set("Authorization", `Bearer ${state.accessToken}`);
|
||||
}
|
||||
headers.set("Authorization", `Bearer ${state.accessToken}`);
|
||||
}
|
||||
|
||||
if (state.selfDeviceName)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// iOS 原生壳的桥接层:仅在 iOS WKWebView「无头引擎」壳中可用。浏览器 / 桌面里
|
||||
// isIOSShell() 返回 false,所有调用点回退既有 Web 行为,因此本模块对纯 Web 与桌面
|
||||
// 构建零影响——同一份引擎代码同时部署到浏览器、桌面与 iOS 无头壳。
|
||||
//
|
||||
// 方向与桌面(Wails)相反,是本模块存在的根由:
|
||||
// - 桌面:React UI 在 WebView 顶层,调 DOWN 到 Go 绑定方法(window.go.main.App.*,
|
||||
// 直接返回 Promise)。
|
||||
// - iOS(arch A,见 ios/PLAN.md §2):原生 SwiftUI 在顶层,本 WebView 是「无头
|
||||
// 传输引擎」。JS→原生只能 postMessage 到 WKScriptMessageHandler(无返回值),
|
||||
// 原生→JS 用 evaluateJavaScript 回调全局函数。故 callNative 以「请求 id + 全局
|
||||
// resolver」把单向 postMessage 拼回请求 / 响应语义。
|
||||
//
|
||||
// 原生侧须实现的契约(Swift 实现据此对齐):
|
||||
// - 注册名为 "cdropEngine" 的 WKScriptMessageHandler,收 { id, method, payload }。
|
||||
// - 处理完一次 callNative 后,evaluateJavaScript 调
|
||||
// window.__cdropEngineResolve(id, ok, value)(ok=false 时 value 为错误串)。
|
||||
// - 推事件(进度 / 状态 / 传入 offer 等)用 evaluateJavaScript 调
|
||||
// window.__cdropEngineEvent(name, payload)。
|
||||
// - 接收落盘四方法 beginDownload / appendDownload / finalizeDownload /
|
||||
// abortDownload 与桌面 desktop.ts 同形(data 为 base64),落 iOS 沙盒文件。
|
||||
|
||||
// WKScriptMessageHandler 的 JS 侧投递接口(仅 postMessage)。
|
||||
interface WebKitMessageHandler
|
||||
{
|
||||
postMessage: (msg: unknown) => 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<number, Pending>();
|
||||
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<T>(method: string, payload?: unknown): Promise<T>
|
||||
{
|
||||
const h = handler();
|
||||
if (!h) { return Promise.reject(new Error("ios bridge unavailable")); }
|
||||
installResolver();
|
||||
const id = nextRequestId;
|
||||
nextRequestId += 1;
|
||||
return new Promise<T>((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<string, Set<EventHandler>>();
|
||||
|
||||
// 安装全局事件入口(幂等):原生用 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<string>
|
||||
{
|
||||
const buf = await blob.arrayBuffer();
|
||||
return callNative<string>("saveDownload", { name, data: bytesToBase64(new Uint8Array(buf)) });
|
||||
}
|
||||
|
||||
// 流式落盘封装:接收端混合 sink 越过内存上限后,把溢出批次交原生写临时文件,finalize
|
||||
// 时改名落定。与桌面 Begin/Append/Finalize/Abort 同语义(见 incomingSink.ts)。
|
||||
export async function beginIncomingDownloadIOS(sessionId: string): Promise<void>
|
||||
{
|
||||
await callNative<void>("beginDownload", { sessionId });
|
||||
}
|
||||
|
||||
export async function appendIncomingDownloadIOS(sessionId: string, bytes: Uint8Array): Promise<void>
|
||||
{
|
||||
await callNative<void>("appendDownload", { sessionId, data: bytesToBase64(bytes) });
|
||||
}
|
||||
|
||||
export async function finalizeIncomingDownloadIOS(sessionId: string, name: string): Promise<string>
|
||||
{
|
||||
return callNative<string>("finalizeDownload", { sessionId, name });
|
||||
}
|
||||
|
||||
export async function abortIncomingDownloadIOS(sessionId: string): Promise<void>
|
||||
{
|
||||
if (!handler()) { return; }
|
||||
try { await callNative<void>("abortDownload", { sessionId }); }
|
||||
catch { /* 取消 / 失败路径,吞掉 */ }
|
||||
}
|
||||
+9
-34
@@ -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<QrRequ
|
||||
requestIp: data.request_ip,
|
||||
expiresAt: data.expires_at,
|
||||
status: data.status,
|
||||
stepUp: data.step_up === true,
|
||||
};
|
||||
}
|
||||
|
||||
// stepUpRequired 标记 approve 因 step-up 再认证缺失 / 过期被后端拒(403
|
||||
// {error:"step_up_required"})。批准页据此把用户带回再认证一遭,而非当成普通错误。
|
||||
export class StepUpRequiredError extends Error
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
super("step_up_required");
|
||||
this.name = "StepUpRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
// qrApprove 批准该设备登入本账号。scope 决定授予的会话级别:full=完整登录、
|
||||
// guest=受限访客;与 persist 时长一一对应(full+persist=7 天滑动、guest+once=1 小时)。
|
||||
// 成功 204。
|
||||
//
|
||||
// step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier,
|
||||
// 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 →
|
||||
// 403 step_up_required(抛 StepUpRequiredError)。step_up 关时这俩字段被后端忽略。
|
||||
// guest=受限访客(经 broker 委托签发对应 tier 的会话)。成功 204。step-up 二次认证
|
||||
// 已移除(full 档即视为可信,路由层 requireFullSession 守门)。
|
||||
export async function qrApprove(
|
||||
requestId: string,
|
||||
code: string,
|
||||
scope: QrScope,
|
||||
persist: QrPersist,
|
||||
stepUpCode?: string,
|
||||
stepUpVerifier?: string,
|
||||
): Promise<void>
|
||||
{
|
||||
const body: Record<string, string> = {
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-53
@@ -33,11 +33,11 @@ export async function fetchMeScope(): Promise<SessionScopeValue>
|
||||
// 会话「种类」: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<AuthSession[]>
|
||||
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<void>
|
||||
{
|
||||
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<void>
|
||||
{
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+47
-93
@@ -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<Outcome>(null);
|
||||
const [ actionError, setActionError ] = useState<string | null>(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()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。 */}
|
||||
<div className={s.choices} role="radiogroup" aria-label={t("qr.approve.trustLabel")}>
|
||||
<TrustOption
|
||||
value="persist"
|
||||
selected={persist === "persist"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.trust.title")}
|
||||
hint={t("qr.approve.trust.hint")}
|
||||
/>
|
||||
<TrustOption
|
||||
value="once"
|
||||
selected={persist === "once"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.once.title")}
|
||||
hint={t("qr.approve.once.hint")}
|
||||
/>
|
||||
</div>
|
||||
{/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。
|
||||
原生客户端不给选择——强制完整登录,故隐藏分段,仅留下方完整权限说明。 */}
|
||||
{!requiresFull && (
|
||||
<div className={s.choices} role="radiogroup" aria-label={t("qr.approve.trustLabel")}>
|
||||
<TrustOption
|
||||
value="persist"
|
||||
selected={persist === "persist"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.trust.title")}
|
||||
hint={t("qr.approve.trust.hint")}
|
||||
/>
|
||||
<TrustOption
|
||||
value="once"
|
||||
selected={persist === "once"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.once.title")}
|
||||
hint={t("qr.approve.once.hint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 权限范围说明:随选中项切换——信任=完整登录、仅此次=受限访客,明确告知
|
||||
这台设备将以什么身份登录(非装饰)。 */}
|
||||
{/* 权限范围说明:原生客户端固定完整登录;浏览器随选中项切换(信任=完整、仅此次=
|
||||
受限访客),明确告知这台设备将以什么身份登录(非装饰)。 */}
|
||||
<Callout tone="info" icon={<ShieldCheck size={16} />}>
|
||||
{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")}
|
||||
</Callout>
|
||||
|
||||
{/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */}
|
||||
{stepUpRequired && !stepUpRejected && (
|
||||
<Callout tone="info" icon={<ShieldCheck size={16} />}>
|
||||
{t("qr.approve.stepUp.notice")}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* 再认证回来仍被后端拒:给方向(凭证过期 / 验证未通过)并允许重试。 */}
|
||||
{stepUpRejected && (
|
||||
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
|
||||
{t("qr.approve.stepUp.rejected")}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{actionError}</Callout>
|
||||
)}
|
||||
@@ -291,13 +247,11 @@ function LinkApprovePage()
|
||||
size="lg"
|
||||
block
|
||||
loading={submitting}
|
||||
leftIcon={
|
||||
stepUpRequired ? <ShieldCheck size={16} /> : <Check size={16} />
|
||||
}
|
||||
leftIcon={<Check size={16} />}
|
||||
onClick={() => { void handleApprove(); }}
|
||||
>
|
||||
{stepUpRequired
|
||||
? t("qr.approve.stepUp.button")
|
||||
{requiresFull
|
||||
? t("qr.approve.trustContinue")
|
||||
: t("qr.approve.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -43,6 +43,7 @@ function LinkNewPage()
|
||||
if (st.status !== "approved" || !st.accessToken || !st.user) { return; }
|
||||
setAuth({
|
||||
accessToken: st.accessToken,
|
||||
refreshToken: st.refreshToken ?? null,
|
||||
user: { id: st.user.id, name: st.user.name, avatar: st.user.avatar },
|
||||
});
|
||||
// 设备名以批准方下发的为权威(服务端登记的名字),回退到本地输入。
|
||||
|
||||
+14
-13
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, LogIn, QrCode } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth";
|
||||
import { loginDev, loginRedirect } from "../features/auth/auth";
|
||||
import { isDesktop, startDesktopLogin } from "../net/desktop";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import { useAppStore } from "../store";
|
||||
@@ -21,24 +21,23 @@ function LoginPage()
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch();
|
||||
const localAuthMode = useAppStore((s) => s.authMode);
|
||||
const user = useAppStore((s) => s.user);
|
||||
|
||||
const [ devUser, setDevUser ] = useState(search.dev_user ?? "");
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
// 前端编译期推断的 mode(DEV/PROD)可能与后端实际部署不一致
|
||||
// (例如把 prod 构建打到 dev 后端测试),因此以服务器返回的为准。
|
||||
const [ serverMode, setServerMode ] = useState<"dev" | "prod" | null>(null);
|
||||
|
||||
// 已登录态落在 /login 时反应式跳首页。全局 SSO 登录回跳后 rd 指回本页(登录前用户
|
||||
// 被 __root 守卫从原路由送到了 /login),bootstrapAuth 据 broker cookie 注入身份后
|
||||
// 若不主动离开,就停在登录页形成“登录成功却又见登录页”的回环。__root 的守卫只把
|
||||
// 未登录态送进 /login,登录态的反向跳转由此补齐(含 dev 同步登录后的兜底)。
|
||||
useEffect(() =>
|
||||
{
|
||||
let cancelled = false;
|
||||
fetchAuthConfig().then(
|
||||
(cfg) => { if (!cancelled) { setServerMode(cfg.auth_mode); } },
|
||||
() => { /* 回退到编译期 mode */ },
|
||||
);
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
if (user) { navigate({ to: "/" }); }
|
||||
}, [ user, navigate ]);
|
||||
|
||||
const effectiveMode = serverMode ?? localAuthMode;
|
||||
// 后端不再暴露 /api/auth/config(OIDC 已委托给 broker);登录模式以前端编译期
|
||||
// 注入的 authMode 为准(dev / prod)。
|
||||
const effectiveMode = localAuthMode;
|
||||
|
||||
const handleDevLogin = () =>
|
||||
{
|
||||
@@ -68,7 +67,9 @@ function LoginPage()
|
||||
}
|
||||
else
|
||||
{
|
||||
await loginProd();
|
||||
// 跳到 broker 全局 SSO 登录(cdrop 后端 302 代跳);登录后回跳本页,
|
||||
// 边缘 human 分支据 broker cookie 注入身份。
|
||||
loginRedirect();
|
||||
}
|
||||
}
|
||||
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Loader } from "@mantine/core";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
clearStepUpState, completeOAuthLogin, verifyStepUpState,
|
||||
} from "../features/auth/auth";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import {
|
||||
clearStepUpPending, peekStepUpPending, stashStepUpCode,
|
||||
} from "../features/qr/stepUp";
|
||||
import { t } from "../i18n";
|
||||
import { Callout } from "../ui/primitives";
|
||||
|
||||
export const Route = createFileRoute("/oauth/callback")({
|
||||
component: CallbackPage,
|
||||
validateSearch: (search: Record<string, unknown>) =>
|
||||
({
|
||||
code: typeof search.code === "string" ? search.code : undefined,
|
||||
state: typeof search.state === "string" ? search.state : undefined,
|
||||
error: typeof search.error === "string" ? search.error : undefined,
|
||||
error_description: typeof search.error_description === "string"
|
||||
? search.error_description
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
function CallbackPage()
|
||||
{
|
||||
const search = Route.useSearch();
|
||||
const [ err, setErr ] = useState<string | null>(null);
|
||||
|
||||
// ref guard:completeOAuthLogin 内部会消费 localStorage 中的 PKCE_VERIFIER /
|
||||
// OAUTH_STATE,二次进入必然 throw "state mismatch / verifier missing",在
|
||||
// navigate(/) 真正完成之前的那一帧引发"登录失败"闪现。
|
||||
//
|
||||
// navigate 是异步的:promise resolve → setErr/navigate;navigate 实际生效之间
|
||||
// 仍可能发生一次 re-render(useNavigate 返回值在某些版本里非稳定引用,会让
|
||||
// useEffect deps 抖动重跑)。ref guard 杜绝逻辑重入,cancelled flag 兜底防止
|
||||
// unmount 后 setErr。
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (startedRef.current) { return; }
|
||||
startedRef.current = true;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// 把 setErr 推迟一拍:navigate({to:"/"}) 是异步的,promise resolve
|
||||
// 后 callback 真正 unmount 前可能渲染一帧 err 状态(即使 success path
|
||||
// 不该走这里,但 setAuth → store 更新 → 父级 re-render → useNavigate
|
||||
// 引用波动 → effect 二度入场是过去的真实路径,残留闪现)。
|
||||
//
|
||||
// 失败时延迟 800ms 显示,给 success 路径足够时间触发卸载;真正失败
|
||||
// 800ms 内 cleanup 不会被调用,setErr 仍能渲染失败态。
|
||||
const showErrDeferred = (msg: string) =>
|
||||
{
|
||||
console.error("[oauth/callback]", msg);
|
||||
window.setTimeout(() =>
|
||||
{
|
||||
if (!cancelled) { setErr(msg); }
|
||||
}, 800);
|
||||
};
|
||||
|
||||
if (search.error)
|
||||
{
|
||||
showErrDeferred(`${search.error}:${search.error_description ?? ""}`);
|
||||
return;
|
||||
}
|
||||
if (!search.code || !search.state)
|
||||
{
|
||||
showErrDeferred(t("oauth.missingParams"));
|
||||
return;
|
||||
}
|
||||
|
||||
// step-up 分叉:批准页发起的 prompt=login 再认证回到这里。此时 code 是要
|
||||
// 交给后端 approve 去 IdP 换的一次性凭证——前端绝不调 /api/auth/exchange
|
||||
// 消费它(那会在服务端轮换会话)。只校 state(防 CSRF,同常规登录口径)、
|
||||
// 把 {code, verifier} 暂存给批准页、清掉「待办」,整页跳回批准页 URL。
|
||||
const pending = peekStepUpPending();
|
||||
if (pending)
|
||||
{
|
||||
if (!verifyStepUpState(search.state))
|
||||
{
|
||||
clearStepUpPending();
|
||||
showErrDeferred("OAuth state mismatch (possible CSRF or stale tab)");
|
||||
return;
|
||||
}
|
||||
stashStepUpCode({ code: search.code, verifier: pending.verifier });
|
||||
clearStepUpPending();
|
||||
// 整页跳转回批准页(returnTo 已在 stash 时通过 open-redirect 校验)。
|
||||
window.location.replace(pending.returnTo);
|
||||
return;
|
||||
}
|
||||
clearStepUpState(); // 非 step-up 路径兜底:清掉可能残留的 step-up state。
|
||||
|
||||
completeOAuthLogin(search.code, search.state).then(
|
||||
// 整页跳转(window.location.replace),不是路由软导航。Safari 下,从
|
||||
// Casdoor 回跳后那条客户端导航会复用 OAuth 流程遗留的 HTTP 连接,而这些
|
||||
// 连接在 Safari 里会卡死后续资源(setup.js / 头像)约 2 分钟——手动刷新即
|
||||
// 恢复,印证是连接状态问题。整页重载拿到全新连接,绕开该 bug;auth 已在
|
||||
// sessionStorage,同标签刷新存活。replace 不入历史,避免后退重触已消费的 code。
|
||||
() => { if (!cancelled) { window.location.replace("/"); } },
|
||||
(e) => { showErrDeferred(e instanceof Error ? e.message : String(e)); },
|
||||
);
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅在 mount 时跑一次;search 由 URL 决定,mount 时即定。
|
||||
|
||||
if (err)
|
||||
{
|
||||
return (
|
||||
<AuthShell title={t("oauth.failed")} hideBrand>
|
||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{err}</Callout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AuthShell title={t("oauth.signingIn")} subtitle="正在向 Casdoor 交换访问令牌…" hideBrand>
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "var(--space-4) 0" }}>
|
||||
<Loader color="var(--accent)" />
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
+24
-120
@@ -8,14 +8,10 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { logout, startStepUpReauth, syncWebDeviceName } from "../features/auth/auth";
|
||||
import { Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { logout, renameDeviceSession, syncWebDeviceName } from "../features/auth/auth";
|
||||
import { DesktopSettings } from "../features/desktop/DesktopSettings";
|
||||
import {
|
||||
consumePendingRevoke, consumeStepUpCode, stashPendingRevoke,
|
||||
} from "../features/qr/stepUp";
|
||||
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
|
||||
import { apiFetch } from "../net/api";
|
||||
import {
|
||||
currentPushState,
|
||||
disablePush,
|
||||
@@ -24,7 +20,7 @@ import {
|
||||
type PushState,
|
||||
} from "../net/push";
|
||||
import {
|
||||
listSessions, postStepUp, revokeSession, StepUpRequiredError,
|
||||
listSessions, revokeSession,
|
||||
type AuthSession,
|
||||
} from "../net/sessions";
|
||||
import { useAppStore } from "../store";
|
||||
@@ -77,21 +73,14 @@ function SettingsPage()
|
||||
setRenaming(true);
|
||||
try
|
||||
{
|
||||
// 先在后端把旧设备记录删掉。Hub.Kick 会强制关闭旧的 SSE,
|
||||
// 紧接着 root effect 检测到 selfDeviceName 变化会以新名重新注册。
|
||||
// 404(旧记录已被 sweeper 清理)也按成功处理。
|
||||
const r = await apiFetch(
|
||||
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!r.ok && r.status !== 404)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
throw new Error(`${r.status}: ${text}`);
|
||||
}
|
||||
// 改名只换 label,不换身份(device_id 稳定):先落本地名(root effect 会以新名重连
|
||||
// SSE),再以同一 device_id 重新代铸,把新 label 推给 broker、原地轮换那条会话,
|
||||
// 故统一设备列表与 presence 都显示新名、不产生重复设备行。桌面在此只更新本地名,
|
||||
// broker label 于下次登录代铸时同步。
|
||||
setSelfDeviceName(trimmedName);
|
||||
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
|
||||
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
|
||||
await renameDeviceSession();
|
||||
toast.ok(t("settings.deviceName.success"));
|
||||
}
|
||||
catch (e)
|
||||
@@ -106,28 +95,15 @@ function SettingsPage()
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnregisterSelf = async () =>
|
||||
const handleUnregisterSelf = () =>
|
||||
{
|
||||
if (!selfDeviceName) { return; }
|
||||
if (!window.confirm(t("settings.unregister.currentConfirm"))) { return; }
|
||||
|
||||
try
|
||||
{
|
||||
await apiFetch(
|
||||
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 即便 DELETE 失败也按用户意图清理本地并跳转登录。
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSelfDeviceName(null);
|
||||
logout();
|
||||
navigate({ to: "/login", search: { dev_user: undefined } });
|
||||
}
|
||||
// logout() 已自吊销本设备的 broker 会话(/api/auth/logout,按 device_id),故无需再按
|
||||
// 设备名 DELETE(迁移后设备路由按 device_id,旧的按名删除一律 404、且属冗余)。
|
||||
setSelfDeviceName(null);
|
||||
logout();
|
||||
navigate({ to: "/login", search: { dev_user: undefined } });
|
||||
};
|
||||
|
||||
const handleSignOut = () =>
|
||||
@@ -348,7 +324,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
const [ sessions, setSessions ] = useState<AuthSession[] | null>(null);
|
||||
const [ loadError, setLoadError ] = useState(false);
|
||||
const [ busyId, setBusyId ] = useState<string | null>(null);
|
||||
const [ stepUpRejected, setStepUpRejected ] = useState(false);
|
||||
|
||||
// reload 拉取并写入列表,同时把结果回传给调用方(step-up 重试路径需在 revoke
|
||||
// 前从这份列表里查出 current,不能在 revoke 之后再拉——若登出的正是本机会话,
|
||||
@@ -375,56 +350,15 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
void reload();
|
||||
}, [ onSignedOutSelf, reload ]);
|
||||
|
||||
// 进页面拉一次。返回时若检测到 step-up 往返暂存({code, verifier} + 待登出 id),
|
||||
// 先 POST /auth/stepup 记录窗口,再重试那条 DELETE——一次性串起整页跳转两端。
|
||||
// 受限访客无权管理会话、后端 GET 会 403,故根本不发请求、直接走说明态。
|
||||
// 进页面拉一次列表。受限访客无权管理会话、后端 GET 会 403,故跳过、走说明态。
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isGuest) { return; }
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () =>
|
||||
{
|
||||
// step-up 往返恢复:取出暂存(一次性)。无暂存则普通进页只拉列表。
|
||||
const stash = consumeStepUpCode();
|
||||
const pendingId = consumePendingRevoke();
|
||||
const list = await reload();
|
||||
if (cancelled) { return; }
|
||||
|
||||
if (stash && pendingId)
|
||||
{
|
||||
// 在 revoke 前就从这份列表里查出待登出的那条(尤其 current 标记):
|
||||
// 若登出的正是本机会话,revoke 后令牌即失效,不能再 listSessions。
|
||||
const target = list?.find((x) => x.id === pendingId);
|
||||
setBusyId(pendingId);
|
||||
try
|
||||
{
|
||||
await postStepUp(stash.code, stash.verifier);
|
||||
await revokeSession(pendingId);
|
||||
if (cancelled) { return; }
|
||||
if (target) { finishRevoke(target); }
|
||||
else { toast.ok(t("settings.sessions.revoked")); await reload(); }
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
if (cancelled) { return; }
|
||||
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
|
||||
else
|
||||
{
|
||||
toast.error(t("settings.error.delete", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
}
|
||||
void reload();
|
||||
}
|
||||
finally { if (!cancelled) { setBusyId(null); } }
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [ isGuest, reload, finishRevoke ]);
|
||||
void reload();
|
||||
}, [ isGuest, reload ]);
|
||||
|
||||
// handleRevoke 登出一条会话(= 一台设备)。后端连带 broker 吊销 + 删设备行。
|
||||
// step-up 二次认证已移除(full 档即可信)。
|
||||
const handleRevoke = async (sess: AuthSession) =>
|
||||
{
|
||||
const confirmMsg = sess.current
|
||||
@@ -433,7 +367,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
if (!window.confirm(confirmMsg)) { return; }
|
||||
|
||||
setBusyId(sess.id);
|
||||
setStepUpRejected(false);
|
||||
try
|
||||
{
|
||||
await revokeSession(sess.id);
|
||||
@@ -441,29 +374,9 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
if (e instanceof StepUpRequiredError)
|
||||
{
|
||||
// 需再认证:暂存待登出 id,发起 prompt=login 往返,回到 /settings 后
|
||||
// 由 mount effect 接力重试。成功则整页跳走、本组件卸载。
|
||||
stashPendingRevoke(sess.id);
|
||||
try
|
||||
{
|
||||
await startStepUpReauth("/settings");
|
||||
return; // 跳转去 provider,不再 setBusyId。
|
||||
}
|
||||
catch (reauthErr)
|
||||
{
|
||||
toast.error(t("settings.error.delete", {
|
||||
message: reauthErr instanceof Error ? reauthErr.message : String(reauthErr),
|
||||
}));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
toast.error(t("settings.error.delete", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
}
|
||||
toast.error(t("settings.error.delete", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
}
|
||||
finally { setBusyId(null); }
|
||||
};
|
||||
@@ -485,11 +398,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
<Panel title={t("settings.sessions.title")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.sessions.hint")}</Text>
|
||||
{stepUpRejected && (
|
||||
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
|
||||
{t("settings.sessions.stepUpRejected")}
|
||||
</Callout>
|
||||
)}
|
||||
{loadError ? (
|
||||
<Group>
|
||||
<Text size="sm" style={{ color: "var(--state-error)" }}>
|
||||
@@ -522,14 +430,10 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
);
|
||||
}
|
||||
|
||||
// 会话种类标签:网页 / 扫码会话用 settings.sessions.kind.*;原生客户端
|
||||
// (macos / windows / linux / ios)复用设备类型标签(「macOS 客户端」等)。
|
||||
// 会话种类标签:迁移后每条会话即一台设备,统一复用设备类型标签
|
||||
// (「网页」/「macOS 客户端」等)。
|
||||
function sessionKindLabel(kind: AuthSession["kind"]): string
|
||||
{
|
||||
if (kind === "oidc" || kind === "self" || kind === "guest")
|
||||
{
|
||||
return t(`settings.sessions.kind.${kind}` as const);
|
||||
}
|
||||
return t(`deviceType.${kind}` as const);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { Check, Monitor } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import { syncWebDeviceName } from "../features/auth/auth";
|
||||
import { ensureDeviceSession, syncWebDeviceName } from "../features/auth/auth";
|
||||
import { persistDesktopDeviceName } from "../net/desktop";
|
||||
import { useAppStore } from "../store";
|
||||
import { t } from "../i18n";
|
||||
@@ -26,9 +26,11 @@ function SetupPage()
|
||||
const navigate = useNavigate();
|
||||
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
|
||||
const [ name, setName ] = useState(suggestDefault());
|
||||
const [ saving, setSaving ] = useState(false);
|
||||
|
||||
const handleSave = () =>
|
||||
const handleSave = async () =>
|
||||
{
|
||||
if (saving) { return; } // 防重复提交:代铸期间再次点击会多铸一条设备会话
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) { return; }
|
||||
if (!isAsciiDeviceName(trimmed))
|
||||
@@ -36,10 +38,21 @@ function SetupPage()
|
||||
toast.warn(t("settings.deviceName.asciiOnly"));
|
||||
return;
|
||||
}
|
||||
setSelfDeviceName(trimmed);
|
||||
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
|
||||
syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
|
||||
navigate({ to: "/" });
|
||||
setSaving(true);
|
||||
try
|
||||
{
|
||||
setSelfDeviceName(trimmed);
|
||||
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
|
||||
syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
|
||||
// 浏览器:名字既定,立即代铸一条托管设备会话(桌面 / 已持令牌者为 no-op),
|
||||
// 使本机以此名进入统一设备列表后再进入应用。
|
||||
await ensureDeviceSession();
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -74,7 +87,7 @@ function SetupPage()
|
||||
block
|
||||
leftIcon={<Check size={16} />}
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim()}
|
||||
disabled={!name.trim() || saving}
|
||||
>
|
||||
{t("setup.save")}
|
||||
</Button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user