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 升级迁移
144 lines
6.8 KiB
SQL
144 lines
6.8 KiB
SQL
-- 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 单独泄露也换不出可用 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 ''
|
||
);
|
||
|
||
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);
|
||
|
||
-- 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 清理。
|
||
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);
|