90a3790a98
身份仍源自 OAuth provider(user_id = OIDC sub),cdrop 在其上自维护一薄层: 自签会话令牌 + accounts 表,并新增扫码快速登录。实施蓝本见 AUTH.md。 后端 · 自签会话原语 - web_sessions 加 kind/scope/granted_by 列(bootstrap 幂等迁移);既有 oidc 会话行为不变,新增 self(滑动续期)/ guest(受限·不续)两类 - cdrop 自签 HS256 会话 access token,密钥派生自 SESSION_SECRET,与落盘 AES、 shortcut HS256 三密钥域隔离;jwtauth.verifySelfToken 无状态校验、靠 typ 区分 - requireFullSession 守卫:guest 会话不得改账号 / 再批准设备 / 签长效 token - /auth/refresh 按 kind 分流:self/guest 纯自签、不触 IdP 后端 · 扫码登录(internal/httpapi/qr.go) - qr/start・status・request・approve・deny 五端点 + login_requests 表 + reaper - 三密钥分离:QR 仅含批准信息,会话只投递给持私有 poll_secret 的原设备 (偷拍 QR 者无 poll_secret 领不到会话、未登录批不了准) - 会话在新设备侧领取(cookie 不经手机)、单次消费、短 TTL 后端 · 薄账户层与 2FA step-up - accounts 表(键 sub,不含任何凭证):exchange/refresh upsert match_key / 显示名 / 头像 / roles,供显示与未来管理员开启迁移标记时跨源关联 - step-up(默认关):开启后 qr/approve 要求新鲜 prompt=login 授权码,后端就地 换 id_token、JWKS 验签 + auth_time 窗口 + sub 匹配,provider 2FA 于此往返强制 前端(web/) - 显码页 /link/new + 批准页 /link + net/qr.ts,对齐 Theme B、复用 AuthShell 与聚珍排版管线、零新全局样式 - step-up 再认证流:批准前跑 prompt=login PKCE,回调分叉(不消费一次性 code、 独立 state key)后带 step_up_code/verifier 调 approve - 三语 i18n qr.*;新增 qrcode 依赖 修复 · clipboard sweeper(早已提交的损坏) - ClearExpiredClipboards 因 clipboard.sql 全角注释触发 sqlc 1.31 多字节偏移 bug,生成 SQL 被截断为「UPDATE clipboard_state SET content =」,sweeper 运行 期报「incomplete input」、过期剪贴板内容从未清除(短 TTL 暴露保护失效) - 注释改纯 ASCII 并加 bug 警告,重生成得完整 SQL;prod 已验证 sweeper 由 ERROR 转为正常清理(cleared count=1) 构建 - .dockerignore:排除本地 node_modules 等,避免宿主原生二进制污染镜像内 vite 构建 - Dockerfile.base:GOPROXY 改为可经 --build-arg 覆盖(默认仍官方代理,受限网络 构建时传区域镜像即可,仓库不固化区域值) 文档 - 新增 AUTH.md(账户与登录实施蓝本);README 特性;.env.example / compose.snippet 增配置项(QR / 自签会话 TTL / step-up / match_claim) 测试 - 自签 token 密钥域隔离、扫码端到端(领取 / 单次 / poll_secret 校验 / deny / step-up 门)、extractIdentity、web_sessions 升级迁移
119 lines
3.8 KiB
Go
119 lines
3.8 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
_ "embed"
|
|
"fmt"
|
|
"net/url"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
//go:embed migrations/0001_init.sql
|
|
var initSchema string
|
|
|
|
// Open dials a sqlite db with WAL + busy timeout pragmas wired in.
|
|
// dbPath is the on-disk file path; passing ":memory:" works for tests.
|
|
func Open(dbPath string) (*sql.DB, error) {
|
|
dsn := buildDSN(dbPath)
|
|
d, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
if err := d.PingContext(context.Background()); err != nil {
|
|
_ = d.Close()
|
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func buildDSN(dbPath string) string {
|
|
q := url.Values{}
|
|
q.Add("_pragma", "journal_mode(WAL)")
|
|
q.Add("_pragma", "busy_timeout(5000)")
|
|
q.Add("_pragma", "foreign_keys(1)")
|
|
return dbPath + "?" + q.Encode()
|
|
}
|
|
|
|
// Bootstrap runs the embedded init schema in a single transaction.
|
|
// Idempotent: every CREATE TABLE uses IF NOT EXISTS.
|
|
func Bootstrap(ctx context.Context, d *sql.DB) error {
|
|
tx, err := d.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin bootstrap tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx, initSchema); err != nil {
|
|
return fmt.Errorf("exec init schema: %w", err)
|
|
}
|
|
// Additive migration for DBs created before clipboard_state.version existed:
|
|
// CREATE TABLE IF NOT EXISTS won't add the column to an existing table, and
|
|
// SQLite has no ADD COLUMN IF NOT EXISTS, so guard it with a pragma check.
|
|
if err := ensureColumn(ctx, tx, "clipboard_state", "version",
|
|
"ALTER TABLE clipboard_state ADD COLUMN version INTEGER NOT NULL DEFAULT 0"); err != nil {
|
|
return fmt.Errorf("migrate clipboard_state.version: %w", err)
|
|
}
|
|
if err := ensureColumn(ctx, tx, "clipboard_state", "origin_ts",
|
|
"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 := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit bootstrap: %w", 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 {
|
|
has, err := columnExists(ctx, tx, table, column)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if has {
|
|
return nil
|
|
}
|
|
_, err = tx.ExecContext(ctx, addSQL)
|
|
return err
|
|
}
|
|
|
|
func columnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) {
|
|
// table is a trusted compile-time constant, not user input.
|
|
rows, err := tx.QueryContext(ctx, "PRAGMA table_info("+table+")")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var (
|
|
cid, notnull, pk int
|
|
name, ctype string
|
|
dflt sql.NullString
|
|
)
|
|
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
|
return false, err
|
|
}
|
|
if name == column {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, rows.Err()
|
|
}
|