Files
Commilitia-Drop/internal/db/migrations/0001_init.sql
T
admin 90a3790a98 扫码登录:cdrop 自签会话原语 + 薄账户层 + 受限访客 + 2FA step-up
身份仍源自 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 升级迁移
2026-06-21 22:43:36 +08:00

144 lines
6.8 KiB
SQL
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.
-- cdrop MVP schema (PROJECT_BRIEF.md §4)
-- 以 IF NOT EXISTS 形式由 internal/db/bootstrap.go 启动期单事务执行。
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)
);
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 TABLE IF NOT EXISTS transfer_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
sender_name TEXT NOT NULL,
receiver_name TEXT NOT NULL,
state TEXT NOT NULL,
mode TEXT,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_sha256 TEXT,
bytes_transferred INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
finished_at INTEGER,
fail_reason TEXT
);
CREATE TABLE IF NOT EXISTS clipboard_state (
user_id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
content TEXT,
source_device TEXT,
updated_at INTEGER NOT NULL,
-- version 是每用户严格单调递增的版本号(每次写 +1),变更探测 / ETag 用;
-- updated_at 仍是墙钟(TTL / 展示)。既有库由 bootstrap 的幂等 ALTER 补列。
version INTEGER NOT NULL DEFAULT 0,
-- origin_ts 是内容在源设备被复制的时刻(ms)。冲突收敛按它做 LWW——写入仅在
-- origin_ts 严格大于现存时被接受,故延迟/乱序到达的旧复制不会盖掉新的。
origin_ts INTEGER NOT NULL DEFAULT 0
);
-- web_sessions:浏览器端「免重登」。cookie 里只放不透明随机串,本表 id 存其
-- SHA-256,故 DB 单独泄露也换不出可用 cookierefresh_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 续);selfguestcdrop 自签会话,无 IdP refresh_token
-- refresh 时仅按本行自签 access token、不触 IdP。既有行经 bootstrap 的幂等 ALTER
-- 补列后默认视为 oidc/full,行为不变。
kind TEXT NOT NULL DEFAULT 'oidc',
-- scopefullguest。guest(扫码受限借用设备)服务端强制受限:能收发文件,
-- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。
scope TEXT NOT NULL DEFAULT 'full',
-- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。
granted_by TEXT NOT NULL DEFAULT ''
);
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,使同一浏览器重复
-- 订阅幂等覆盖(endpoint 唯一标识一条推送通道)。p256dh/auth 是 RFC 8291 消息加密
-- 用的客户端公钥与鉴权密钥(base64url)。按 (user_id, device_name) 定位收件设备,
-- 与 hub.SendTo 用 device_name 作 deviceID 一致。桌面端不入此表——它走本地原生通知。
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
device_name TEXT NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
-- locale 随订阅存:推送文案在服务端渲染(SW 只负责展示收到的 title/body),
-- 故须知道该设备的界面语言;用户切换语言时前端重新订阅会幂等刷新此列。
locale TEXT NOT NULL DEFAULT 'zh-CN',
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_device
ON push_subscriptions (user_id, device_name);
-- accountscdrop 侧「薄账户数据层」(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),consumedexpired 后不可复用,由 reaper 清理。
CREATE TABLE IF NOT EXISTS login_requests (
id TEXT PRIMARY KEY,
poll_secret TEXT NOT NULL,
approval_code TEXT NOT NULL,
status TEXT NOT NULL,
new_device_name TEXT NOT NULL,
new_device_type TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
request_ip TEXT NOT NULL DEFAULT '',
approver_user_id TEXT NOT NULL DEFAULT '',
grant_scope TEXT NOT NULL DEFAULT '',
grant_persist TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
approved_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires_at);