Files
Commilitia-Drop/internal/config/config.go
T
admin e51666c52e 浏览器免重登:HttpOnly cookie + 服务端 session 表 + 设备名持久化
- 新增 web_sessions 表(sqlc):refresh_token 以 AES-256-GCM 落盘,密钥取自
  CDROP_SESSION_SECRET(env、不在库内);cookie 仅存不透明随机串,表 id 存其
  SHA-256,故 DB 单独泄露既换不出可用 cookie、也解不出 token
- /auth/exchange 建 session 并下发 HttpOnly; Secure; SameSite=Lax; Path=/api/auth
  cookie,响应体不再回传 refresh_token
- /auth/refresh 改为 cookie 驱动(7 天滑动失活),同时即开机静默免重登;
  新增 /auth/logout(删 session + 清 cookie)、/auth/device(写设备名入 session)
- device_name 随 session 存,开机 refresh 带回前端,抗 iOS PWA 存储清除;
  setup / 改名时 syncWebDeviceName 推送服务端
- striped per-session 锁串行化同会话并发 refresh,防一次性 refresh_token 被
  花两次而把用户从所有 tab 登出
- 前端 store 移除 refreshToken 字段(长寿命凭据彻底不进 JS);main.tsx 首屏前
  bootstrapAuth 用 cookie 静默续期,命中直接进已登录态、避免登录页闪现
- config:prod 强制 CDROP_SESSION_SECRET,缺失拒启动
- 桌面端不受影响(自有 loopback flow + Go keyring,不碰这两个端点)
2026-06-16 00:54:40 +08:00

150 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"errors"
"fmt"
"os"
"strings"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env/v2"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
const envPrefix = "CDROP_"
type Config struct {
AuthMode string `koanf:"auth_mode"`
DevToken string `koanf:"dev_token"`
DBPath string `koanf:"db_path"`
Listen string `koanf:"listen"`
// DeviceTTLHours is the maximum age of a row in `devices` before the
// background sweeper deletes it. Brief §2 sets the refresh_token sliding
// 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"`
HS256Secret string `koanf:"hs256_secret"`
// 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"`
// Shortcut tokensiOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期
// (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。
// 需配 HS256Secret 才启用,否则签发端点返回 503。
ShortcutTokenTTLDays int `koanf:"shortcut_token_ttl_days"`
ShortcutMaxPerUser int `koanf:"shortcut_max_per_user"`
// Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/).
// When both fields are set, /api/calls/credentials returns short-lived
// TLS-capable TURN creds (turns:turn.cloudflare.com:5349); otherwise the
// endpoint serves a STUN-only fallback so dev / no-CF deploys still work.
CFTurnKeyID string `koanf:"cf_turn_key_id"`
CFTurnAPIToken string `koanf:"cf_turn_api_token"`
// Clipboard:单条覆写式同步。MaxBytes 限制单次写入字节数;DebounceSec 是
// 收到 PUT 后等待"无新写入稳定窗口"的秒数,到期才广播 SSE。同窗口内的
// 后续 PUT 直接刷新 generation 让旧广播取消。
ClipboardMaxBytes int `koanf:"clipboard_max_bytes"`
ClipboardDebounceSec int `koanf:"clipboard_debounce_sec"`
// ClipboardTTLSec 是云剪贴板内容的存活秒数。因为无法可靠区分密码与普通
// 文本(密码.app / 浏览器扩展复制都不打敏感标记),改以短 TTL 限制暴露面:
// 内容超过 TTL 后 GET 返回空,后台 sweeper 亦清除其 content。0 = 不过期。
ClipboardTTLSec int `koanf:"clipboard_ttl_sec"`
}
// Load reads config from optional ./config.yaml then overrides with CDROP_* env.
// Validates dev/prod invariants; returns error if invariants are violated.
func Load() (*Config, error) {
k := koanf.New(".")
k.Set("auth_mode", "prod")
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)
if _, err := os.Stat("config.yaml"); err == nil {
if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil {
return nil, fmt.Errorf("load config.yaml: %w", err)
}
}
envProvider := env.Provider(".", env.Opt{
Prefix: envPrefix,
TransformFunc: func(key, value string) (string, any) {
return strings.ToLower(strings.TrimPrefix(key, envPrefix)), value
},
})
if err := k.Load(envProvider, nil); err != nil {
return nil, fmt.Errorf("load env: %w", err)
}
cfg := &Config{}
if err := k.Unmarshal("", cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) validate() error {
switch c.AuthMode {
case "dev":
if c.DevToken == "" {
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 == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " +
"(comma-separated OAuth client_ids); 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 == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_SESSION_SECRET " +
"(keys web-session refresh_token encryption); refusing to start")
}
default:
return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode)
}
return nil
}