扫码登录: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 升级迁移
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: accounts.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const getAccount = `-- name: GetAccount :one
|
||||
SELECT user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at
|
||||
FROM accounts
|
||||
WHERE user_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetAccount(ctx context.Context, userID string) (Account, error) {
|
||||
row := q.db.QueryRowContext(ctx, getAccount, userID)
|
||||
var i Account
|
||||
err := row.Scan(
|
||||
&i.UserID,
|
||||
&i.MatchKey,
|
||||
&i.DisplayName,
|
||||
&i.AvatarUrl,
|
||||
&i.Roles,
|
||||
&i.Provider,
|
||||
&i.CreatedAt,
|
||||
&i.LastLoginAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertAccount = `-- name: UpsertAccount :exec
|
||||
|
||||
INSERT INTO accounts (user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
match_key = excluded.match_key,
|
||||
display_name = excluded.display_name,
|
||||
avatar_url = excluded.avatar_url,
|
||||
roles = excluded.roles,
|
||||
provider = excluded.provider,
|
||||
last_login_at = excluded.last_login_at
|
||||
`
|
||||
|
||||
type UpsertAccountParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
MatchKey string `json:"match_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
Roles string `json:"roles"`
|
||||
Provider string `json:"provider"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastLoginAt int64 `json:"last_login_at"`
|
||||
}
|
||||
|
||||
// accounts: cdrop-side thin account data (AUTH.md 2.1). Keyed on user_id (= OIDC
|
||||
// sub). No credentials live here; password / second factor stay at the OAuth
|
||||
// provider. Upserted on every successful OIDC exchange.
|
||||
//
|
||||
// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and
|
||||
// corrupts every generated SQL const in the file. Keep this file pure ASCII.
|
||||
func (q *Queries) UpsertAccount(ctx context.Context, arg UpsertAccountParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertAccount,
|
||||
arg.UserID,
|
||||
arg.MatchKey,
|
||||
arg.DisplayName,
|
||||
arg.AvatarUrl,
|
||||
arg.Roles,
|
||||
arg.Provider,
|
||||
arg.CreatedAt,
|
||||
arg.LastLoginAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -59,6 +59,21 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestBootstrapCreatesAllTables(t *testing.T) {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"clipboard_state", "devices", "push_subscriptions", "shortcut_tokens", "transfer_sessions", "web_sessions"}
|
||||
want := []string{"accounts", "clipboard_state", "devices", "login_requests", "push_subscriptions", "shortcut_tokens", "transfer_sessions", "web_sessions"}
|
||||
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
t.Fatalf("query tables: %v", err)
|
||||
@@ -114,3 +114,45 @@ func TestBootstrapAddsClipboardVersionToLegacyDB(t *testing.T) {
|
||||
t.Fatalf("second bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Simulates the prod upgrade for scan-login: a web_sessions table created before
|
||||
// kind/scope/granted_by existed must gain them, with an existing row defaulting
|
||||
// to a normal OIDC-backed full session so its behaviour is unchanged (AUTH.md §2.2).
|
||||
func TestBootstrapAddsWebSessionColumnsToLegacyDB(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "legacy.db")
|
||||
d, err := Open(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
_, err = d.Exec(`CREATE TABLE 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)`)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy schema: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`INSERT INTO web_sessions (id, user_id, refresh_token, created_at, last_used_at, expires_at)
|
||||
VALUES ('s', 'u', 'enc', 1, 1, 9999999999)`); err != nil {
|
||||
t.Fatalf("seed row: %v", err)
|
||||
}
|
||||
|
||||
if err := Bootstrap(context.Background(), d); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
var kind, scope, grantedBy string
|
||||
if err := d.QueryRow(`SELECT kind, scope, granted_by FROM web_sessions WHERE id='s'`).
|
||||
Scan(&kind, &scope, &grantedBy); err != nil {
|
||||
t.Fatalf("select new columns (missing?): %v", err)
|
||||
}
|
||||
if kind != "oidc" || scope != "full" || grantedBy != "" {
|
||||
t.Errorf("legacy row defaults: got kind=%q scope=%q granted_by=%q, want oidc/full/empty",
|
||||
kind, scope, grantedBy)
|
||||
}
|
||||
// Idempotent: second bootstrap must not error on already-present columns.
|
||||
if err := Bootstrap(context.Background(), d); err != nil {
|
||||
t.Fatalf("second bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,17 @@ import (
|
||||
|
||||
const clearExpiredClipboards = `-- name: ClearExpiredClipboards :execrows
|
||||
UPDATE clipboard_state
|
||||
SET content =
|
||||
SET content = NULL, source_device = NULL
|
||||
WHERE content IS NOT NULL AND updated_at < ?
|
||||
`
|
||||
|
||||
// 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空),
|
||||
// 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。
|
||||
// ClearExpiredClipboards empties content older than the cutoff (content /
|
||||
// source_device set NULL) for the short-TTL exposure limit; the row is kept.
|
||||
// Called periodically by the clipboard sweeper.
|
||||
//
|
||||
// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and
|
||||
// truncates the generated SQL const. This query was silently cut to "SET content ="
|
||||
// (the sweeper then failed at runtime with "incomplete input"). Keep ASCII.
|
||||
func (q *Queries) ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, clearExpiredClipboards, updatedAt)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: login_requests.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const approveLoginRequest = `-- name: ApproveLoginRequest :execrows
|
||||
UPDATE login_requests
|
||||
SET status = 'approved', approver_user_id = ?, grant_scope = ?, grant_persist = ?, approved_at = ?
|
||||
WHERE id = ? AND status = 'pending'
|
||||
`
|
||||
|
||||
type ApproveLoginRequestParams struct {
|
||||
ApproverUserID string `json:"approver_user_id"`
|
||||
GrantScope string `json:"grant_scope"`
|
||||
GrantPersist string `json:"grant_persist"`
|
||||
ApprovedAt *int64 `json:"approved_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// ApproveLoginRequest flips pending -> approved and records who approved it plus
|
||||
// the granted scope/persistence. execrows so the handler can tell whether it
|
||||
// actually transitioned (0 = already consumed / denied / gone).
|
||||
func (q *Queries) ApproveLoginRequest(ctx context.Context, arg ApproveLoginRequestParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, approveLoginRequest,
|
||||
arg.ApproverUserID,
|
||||
arg.GrantScope,
|
||||
arg.GrantPersist,
|
||||
arg.ApprovedAt,
|
||||
arg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const consumeLoginRequest = `-- name: ConsumeLoginRequest :execrows
|
||||
UPDATE login_requests
|
||||
SET status = 'consumed'
|
||||
WHERE id = ? AND status = 'approved'
|
||||
`
|
||||
|
||||
// ConsumeLoginRequest flips approved -> consumed when the new device collects its
|
||||
// session, making the request single-use.
|
||||
func (q *Queries) ConsumeLoginRequest(ctx context.Context, id string) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, consumeLoginRequest, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const createLoginRequest = `-- name: CreateLoginRequest :exec
|
||||
|
||||
INSERT INTO login_requests (id, poll_secret, approval_code, status, new_device_name, new_device_type, user_agent, request_ip, created_at, expires_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateLoginRequestParams struct {
|
||||
ID string `json:"id"`
|
||||
PollSecret string `json:"poll_secret"`
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
NewDeviceName string `json:"new_device_name"`
|
||||
NewDeviceType string `json:"new_device_type"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
RequestIp string `json:"request_ip"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// login_requests: pending scan-login (QR) approvals (AUTH.md 2.3, 4). poll_secret
|
||||
// is stored as its SHA-256 (the new device holds the plaintext); approval_code
|
||||
// travels in the QR. Short-lived; reaped by DeleteExpiredLoginRequests.
|
||||
//
|
||||
// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and
|
||||
// corrupts every generated SQL const in the file. Keep this file pure ASCII.
|
||||
func (q *Queries) CreateLoginRequest(ctx context.Context, arg CreateLoginRequestParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createLoginRequest,
|
||||
arg.ID,
|
||||
arg.PollSecret,
|
||||
arg.ApprovalCode,
|
||||
arg.NewDeviceName,
|
||||
arg.NewDeviceType,
|
||||
arg.UserAgent,
|
||||
arg.RequestIp,
|
||||
arg.CreatedAt,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteExpiredLoginRequests = `-- name: DeleteExpiredLoginRequests :execrows
|
||||
DELETE FROM login_requests
|
||||
WHERE expires_at < ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredLoginRequests(ctx context.Context, expiresAt int64) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteExpiredLoginRequests, expiresAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const denyLoginRequest = `-- name: DenyLoginRequest :execrows
|
||||
UPDATE login_requests
|
||||
SET status = 'denied'
|
||||
WHERE id = ? AND status = 'pending'
|
||||
`
|
||||
|
||||
func (q *Queries) DenyLoginRequest(ctx context.Context, id string) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, denyLoginRequest, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getLoginRequest = `-- name: GetLoginRequest :one
|
||||
SELECT id, poll_secret, approval_code, status, new_device_name, new_device_type, user_agent, request_ip, approver_user_id, grant_scope, grant_persist, created_at, expires_at, approved_at
|
||||
FROM login_requests
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetLoginRequest(ctx context.Context, id string) (LoginRequest, error) {
|
||||
row := q.db.QueryRowContext(ctx, getLoginRequest, id)
|
||||
var i LoginRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.PollSecret,
|
||||
&i.ApprovalCode,
|
||||
&i.Status,
|
||||
&i.NewDeviceName,
|
||||
&i.NewDeviceType,
|
||||
&i.UserAgent,
|
||||
&i.RequestIp,
|
||||
&i.ApproverUserID,
|
||||
&i.GrantScope,
|
||||
&i.GrantPersist,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.ApprovedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -63,7 +63,17 @@ CREATE TABLE IF NOT EXISTS web_sessions (
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER NOT NULL,
|
||||
expires_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);
|
||||
@@ -91,3 +101,43 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
|
||||
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);
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
|
||||
package db
|
||||
|
||||
type Account struct {
|
||||
UserID string `json:"user_id"`
|
||||
MatchKey string `json:"match_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
Roles string `json:"roles"`
|
||||
Provider string `json:"provider"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastLoginAt int64 `json:"last_login_at"`
|
||||
}
|
||||
|
||||
type ClipboardState struct {
|
||||
UserID string `json:"user_id"`
|
||||
ContentType string `json:"content_type"`
|
||||
@@ -21,6 +32,23 @@ type Device struct {
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
ID string `json:"id"`
|
||||
PollSecret string `json:"poll_secret"`
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
Status string `json:"status"`
|
||||
NewDeviceName string `json:"new_device_name"`
|
||||
NewDeviceType string `json:"new_device_type"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
RequestIp string `json:"request_ip"`
|
||||
ApproverUserID string `json:"approver_user_id"`
|
||||
GrantScope string `json:"grant_scope"`
|
||||
GrantPersist string `json:"grant_persist"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
ApprovedAt *int64 `json:"approved_at"`
|
||||
}
|
||||
|
||||
type PushSubscription struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
@@ -70,4 +98,7 @@ type WebSession struct {
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Kind string `json:"kind"`
|
||||
Scope string `json:"scope"`
|
||||
GrantedBy string `json:"granted_by"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- accounts: cdrop-side thin account data (AUTH.md 2.1). Keyed on user_id (= OIDC
|
||||
-- sub). No credentials live here; password / second factor stay at the OAuth
|
||||
-- provider. Upserted on every successful OIDC exchange.
|
||||
--
|
||||
-- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and
|
||||
-- corrupts every generated SQL const in the file. Keep this file pure ASCII.
|
||||
|
||||
-- name: UpsertAccount :exec
|
||||
INSERT INTO accounts (user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
match_key = excluded.match_key,
|
||||
display_name = excluded.display_name,
|
||||
avatar_url = excluded.avatar_url,
|
||||
roles = excluded.roles,
|
||||
provider = excluded.provider,
|
||||
last_login_at = excluded.last_login_at;
|
||||
|
||||
-- name: GetAccount :one
|
||||
SELECT user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at
|
||||
FROM accounts
|
||||
WHERE user_id = ?;
|
||||
@@ -20,9 +20,14 @@ SELECT user_id, content_type, content, source_device, updated_at, version, origi
|
||||
FROM clipboard_state
|
||||
WHERE user_id = ?;
|
||||
|
||||
-- ClearExpiredClipboards empties content older than the cutoff (content /
|
||||
-- source_device set NULL) for the short-TTL exposure limit; the row is kept.
|
||||
-- Called periodically by the clipboard sweeper.
|
||||
--
|
||||
-- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and
|
||||
-- truncates the generated SQL const. This query was silently cut to "SET content ="
|
||||
-- (the sweeper then failed at runtime with "incomplete input"). Keep ASCII.
|
||||
-- name: ClearExpiredClipboards :execrows
|
||||
-- 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空),
|
||||
-- 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。
|
||||
UPDATE clipboard_state
|
||||
SET content = NULL, source_device = NULL
|
||||
WHERE content IS NOT NULL AND updated_at < ?;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- login_requests: pending scan-login (QR) approvals (AUTH.md 2.3, 4). poll_secret
|
||||
-- is stored as its SHA-256 (the new device holds the plaintext); approval_code
|
||||
-- travels in the QR. Short-lived; reaped by DeleteExpiredLoginRequests.
|
||||
--
|
||||
-- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and
|
||||
-- corrupts every generated SQL const in the file. Keep this file pure ASCII.
|
||||
|
||||
-- name: CreateLoginRequest :exec
|
||||
INSERT INTO login_requests (id, poll_secret, approval_code, status, new_device_name, new_device_type, user_agent, request_ip, created_at, expires_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetLoginRequest :one
|
||||
SELECT id, poll_secret, approval_code, status, new_device_name, new_device_type, user_agent, request_ip, approver_user_id, grant_scope, grant_persist, created_at, expires_at, approved_at
|
||||
FROM login_requests
|
||||
WHERE id = ?;
|
||||
|
||||
-- ApproveLoginRequest flips pending -> approved and records who approved it plus
|
||||
-- the granted scope/persistence. execrows so the handler can tell whether it
|
||||
-- actually transitioned (0 = already consumed / denied / gone).
|
||||
-- name: ApproveLoginRequest :execrows
|
||||
UPDATE login_requests
|
||||
SET status = 'approved', approver_user_id = ?, grant_scope = ?, grant_persist = ?, approved_at = ?
|
||||
WHERE id = ? AND status = 'pending';
|
||||
|
||||
-- name: DenyLoginRequest :execrows
|
||||
UPDATE login_requests
|
||||
SET status = 'denied'
|
||||
WHERE id = ? AND status = 'pending';
|
||||
|
||||
-- ConsumeLoginRequest flips approved -> consumed when the new device collects its
|
||||
-- session, making the request single-use.
|
||||
-- name: ConsumeLoginRequest :execrows
|
||||
UPDATE login_requests
|
||||
SET status = 'consumed'
|
||||
WHERE id = ? AND status = 'approved';
|
||||
|
||||
-- name: DeleteExpiredLoginRequests :execrows
|
||||
DELETE FROM login_requests
|
||||
WHERE expires_at < ?;
|
||||
@@ -9,7 +9,7 @@ INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, c
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetWebSession :one
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
|
||||
FROM web_sessions
|
||||
WHERE id = ?;
|
||||
|
||||
@@ -30,3 +30,33 @@ WHERE id = ?;
|
||||
-- name: DeleteExpiredWebSessions :execrows
|
||||
DELETE FROM web_sessions
|
||||
WHERE expires_at < ?;
|
||||
|
||||
-- CreateSelfSession inserts a cdrop self-signed session (kind self/guest) that has
|
||||
-- no IdP refresh_token (stored empty): refresh mints a fresh access token straight
|
||||
-- from this row without touching the IdP. Used by QR scan-login (AUTH.md 4).
|
||||
-- name: CreateSelfSession :exec
|
||||
INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, kind, scope, granted_by, created_at, last_used_at, expires_at)
|
||||
VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- SlideWebSession pushes a session's sliding window forward without rotating any
|
||||
-- token. Self/guest sessions use it on refresh (they have no refresh_token to
|
||||
-- rotate); oidc sessions keep using RotateWebSession.
|
||||
-- name: SlideWebSession :exec
|
||||
UPDATE web_sessions
|
||||
SET last_used_at = ?, expires_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- ListWebSessionsByUser lists a user's sessions for the management UI (revoke
|
||||
-- borrowed / guest devices). granted_by ties a scan-approved session to its
|
||||
-- approver for cascade revocation.
|
||||
-- name: ListWebSessionsByUser :many
|
||||
SELECT id, user_id, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
|
||||
FROM web_sessions
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_used_at DESC;
|
||||
|
||||
-- DeleteWebSessionForUser drops one session scoped to its owner, so a user can
|
||||
-- only revoke their own.
|
||||
-- name: DeleteWebSessionForUser :execrows
|
||||
DELETE FROM web_sessions
|
||||
WHERE id = ? AND user_id = ?;
|
||||
|
||||
@@ -9,6 +9,43 @@ import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createSelfSession = `-- name: CreateSelfSession :exec
|
||||
INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, kind, scope, granted_by, created_at, last_used_at, expires_at)
|
||||
VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateSelfSessionParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Kind string `json:"kind"`
|
||||
Scope string `json:"scope"`
|
||||
GrantedBy string `json:"granted_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// CreateSelfSession inserts a cdrop self-signed session (kind self/guest) that has
|
||||
// no IdP refresh_token (stored empty): refresh mints a fresh access token straight
|
||||
// from this row without touching the IdP. Used by QR scan-login (AUTH.md 4).
|
||||
func (q *Queries) CreateSelfSession(ctx context.Context, arg CreateSelfSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createSelfSession,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.DeviceName,
|
||||
arg.UserAgent,
|
||||
arg.Kind,
|
||||
arg.Scope,
|
||||
arg.GrantedBy,
|
||||
arg.CreatedAt,
|
||||
arg.LastUsedAt,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createWebSession = `-- name: CreateWebSession :exec
|
||||
|
||||
INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at)
|
||||
@@ -68,8 +105,28 @@ func (q *Queries) DeleteWebSession(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteWebSessionForUser = `-- name: DeleteWebSessionForUser :execrows
|
||||
DELETE FROM web_sessions
|
||||
WHERE id = ? AND user_id = ?
|
||||
`
|
||||
|
||||
type DeleteWebSessionForUserParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// DeleteWebSessionForUser drops one session scoped to its owner, so a user can
|
||||
// only revoke their own.
|
||||
func (q *Queries) DeleteWebSessionForUser(ctx context.Context, arg DeleteWebSessionForUserParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteWebSessionForUser, arg.ID, arg.UserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getWebSession = `-- name: GetWebSession :one
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
|
||||
FROM web_sessions
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -86,10 +143,70 @@ func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, err
|
||||
&i.CreatedAt,
|
||||
&i.LastUsedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.Kind,
|
||||
&i.Scope,
|
||||
&i.GrantedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listWebSessionsByUser = `-- name: ListWebSessionsByUser :many
|
||||
SELECT id, user_id, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
|
||||
FROM web_sessions
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_used_at DESC
|
||||
`
|
||||
|
||||
type ListWebSessionsByUserRow struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Kind string `json:"kind"`
|
||||
Scope string `json:"scope"`
|
||||
GrantedBy string `json:"granted_by"`
|
||||
}
|
||||
|
||||
// ListWebSessionsByUser lists a user's sessions for the management UI (revoke
|
||||
// borrowed / guest devices). granted_by ties a scan-approved session to its
|
||||
// approver for cascade revocation.
|
||||
func (q *Queries) ListWebSessionsByUser(ctx context.Context, userID string) ([]ListWebSessionsByUserRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listWebSessionsByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListWebSessionsByUserRow
|
||||
for rows.Next() {
|
||||
var i ListWebSessionsByUserRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.DeviceName,
|
||||
&i.UserAgent,
|
||||
&i.CreatedAt,
|
||||
&i.LastUsedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.Kind,
|
||||
&i.Scope,
|
||||
&i.GrantedBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const rotateWebSession = `-- name: RotateWebSession :exec
|
||||
UPDATE web_sessions
|
||||
SET refresh_token = ?, last_used_at = ?, expires_at = ?
|
||||
@@ -128,3 +245,23 @@ func (q *Queries) SetWebSessionDevice(ctx context.Context, arg SetWebSessionDevi
|
||||
_, err := q.db.ExecContext(ctx, setWebSessionDevice, arg.DeviceName, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const slideWebSession = `-- name: SlideWebSession :exec
|
||||
UPDATE web_sessions
|
||||
SET last_used_at = ?, expires_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type SlideWebSessionParams struct {
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// SlideWebSession pushes a session's sliding window forward without rotating any
|
||||
// token. Self/guest sessions use it on refresh (they have no refresh_token to
|
||||
// rotate); oidc sessions keep using RotateWebSession.
|
||||
func (q *Queries) SlideWebSession(ctx context.Context, arg SlideWebSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, slideWebSession, arg.LastUsedAt, arg.ExpiresAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user