a2cad11224
- Req 1 子会话(iOS 多进程在用户视角=一台设备一会话,内部多条独立刷新逻辑会话):brokerclient 加 MintParams.Sub + SessionInfo.Sub + RevokeDeviceSessions(user,meta) 级联;device-session sub!="" 挂在主 device_id 之下、走 tier=clipboard 且不建第二个 device 行;handleSessionsList 按 meta 归并、隐藏 sub!=""(但保留“仅控件会话存活”的设备,不简单丢弃);revokeDevice 改走 meta 级联(移除设备连带吊控件子会话)。iOS provisionWidgetSessionIfNeeded 改用主 device_id + sub="widget"(弃用独立 dev_widget),控件令牌仍隔离持有、独立刷新——不碰引擎主会话,#7 隔离不变。蓝本 auth/docs/子会话方案.md(cdrop 提案 + Broker 评审接受 + cdrop 确认 6 点:用 sub、R1 cdrop 归并、scope 复用 tier=clipboard、级联 DELETE …?meta=)。 - Req 2a 离线消息队列:新增 pending_messages 表(0001_init + sqlc 查询);message.go 收件设备离线即入队(无论是否配推送都入队,恒 202),修“离线消息只随推送横幅一闪、不入收件列表”;GET /api/messages/pending 取即删(DELETE..RETURNING,单次投递);web hub.ts onOpen 每次 SSE 连接 / 重连即拉取补收、逐条 addMessage(全端受益,iOS 经桥推原生 + 累积未读);复用 login-request reaper 清 TTL。 - Req 3 被登出推送:push 加 KindSessionRevoked + 本地化文案,apns/web 双通道;revokeDevice 加 notifyRevoked 参(跨端 revoke=true、自登出=false),跨端移除时推送告知被踢设备;iOS AppDelegate 收 session:revoked 即清 Keychain 主会话 + 控件会话、发 .cdropSessionRevoked,前台经 AppRoot 即时回登录页、关闭态下次启动即登出。 - #6 后台唤醒层:apns apsEnvelope 加 content-available:1(服务端有消息时既弹可点横幅又短暂后台唤醒);iOS DeviceItem 加 Codable、presence 快照持久化(冷启即时显示设备列表,缓解“长时间重连”观感,SSE 一连即整组替换自校正);控件会话回前台补铸(scenePhase active)+ content-available 唤醒时刷新隔离的控件会话(剪贴板保活,绝不碰引擎主会话以免 #7 回归)。 - 测试:qr_test mock broker 加 sub 幂等键 + 级联吊销端点;bootstrap_test 期望表集加 pending_messages。
204 lines
7.4 KiB
Go
204 lines
7.4 KiB
Go
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
|
|
// Sub, when non-empty, mints a subordinate session under the SAME device (DeviceID is the
|
|
// main device's id) instead of a new device: it shares the device identity (one device in
|
|
// every list) but holds its own independently-refreshed tokens. cdrop uses "widget" for the
|
|
// clipboard control / home-screen widget extension (separate OS process), scoped to clipboard
|
|
// only. Requires a non-empty DeviceID (the main device to attach to).
|
|
Sub string `json:"sub"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Sub != "" mints a subordinate session under an existing device (DeviceID is the main
|
|
// device's id), not a new device. It must attach to a concrete device_id (no auto-gen).
|
|
sub := strings.TrimSpace(req.Sub)
|
|
if sub != "" && !validSub(sub) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid sub"})
|
|
return
|
|
}
|
|
|
|
deviceID := strings.TrimSpace(req.DeviceID)
|
|
if deviceID == "" {
|
|
if sub != "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "sub requires device_id"})
|
|
return
|
|
}
|
|
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. A subordinate session instead carries a reduced capability scope
|
|
// (clipboard) — the widget extension can only read/write the clipboard, not act as the device.
|
|
tier := "full"
|
|
if claims.Guest() {
|
|
tier = "guest"
|
|
}
|
|
if sub != "" {
|
|
tier = "clipboard"
|
|
}
|
|
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,
|
|
Sub: sub,
|
|
})
|
|
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()
|
|
// A subordinate session shares the main device's row — do NOT create a second device row
|
|
// (it would collide on the device_id PK / surface as a duplicate device). The main device's
|
|
// row already represents this device in every list; the sub is revoked via the meta cascade.
|
|
if sub == "" {
|
|
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,
|
|
})
|
|
}
|
|
|
|
// validSub accepts a subordinate-session discriminator: a short [a-z0-9_-] token (broker-safe,
|
|
// control-byte-free). Today cdrop only mints "widget" (clipboard control / home-screen widget);
|
|
// the format check reserves room for future extension processes without re-validating per value.
|
|
func validSub(sub string) bool {
|
|
if len(sub) == 0 || len(sub) > 32 {
|
|
return false
|
|
}
|
|
for _, c := range sub {
|
|
switch {
|
|
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '_', c == '-':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|