扫码登录: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:
2026-06-21 22:43:36 +08:00
parent eda23f79b1
commit 90a3790a98
50 changed files with 4130 additions and 43 deletions
+33
View File
@@ -82,6 +82,32 @@ type Config struct {
VAPIDPublicKey string `koanf:"vapid_public_key"`
VAPIDPrivateKey string `koanf:"vapid_private_key"`
VAPIDSubject string `koanf:"vapid_subject"`
// 扫码登录(AUTH.md §4+ cdrop 自签会话(§3)。cdrop 为「没有 IdP refresh_token」
// 的会话(扫码批准的设备、受限访客借用)自签短效 access tokenHS256,密钥派生自
// SessionSecret)。QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503;其余为各 TTL。
QRLoginEnabled bool `koanf:"qr_login_enabled"`
QRRequestTTLSeconds int `koanf:"qr_request_ttl_seconds"`
QRGuestTTLSeconds int `koanf:"qr_guest_ttl_seconds"`
QRPersistTTLHours int `koanf:"qr_persist_ttl_hours"`
SessionTokenTTLSeconds int `koanf:"session_token_ttl_seconds"`
// StepUpEnabled 给敏感动作(批准扫码设备)加 provider 再认证门(AUTH.md §6):开启后
// qr/approve 必须带一份新鲜的 prompt=login 授权码,后端就地换取 id_token、验签并校验
// auth_time 在窗口内。默认关。StepUpMaxAgeSeconds 是该新鲜度窗口(秒),默认 300。
StepUpEnabled bool `koanf:"step_up_enabled"`
StepUpMaxAgeSeconds int `koanf:"step_up_max_age_seconds"`
// AccountMatchClaim 是写入 accounts.match_key 的 JWT claim,供管理员开启「迁移
// 标记」时跨 provider 关联同一账号(AUTH.md §2.1/§7)。默认 email。
AccountMatchClaim string `koanf:"account_match_claim"`
}
// QRLoginOn reports whether scan-login is enabled and the self-signed session
// machinery it relies on is keyable (SessionSecret present). Without the secret
// cdrop can't sign session tokens, so QR stays off regardless of the flag.
func (c *Config) QRLoginOn() bool {
return c.QRLoginEnabled && c.SessionSecret != ""
}
// Load reads config from optional ./config.yaml then overrides with CDROP_* env.
@@ -98,6 +124,13 @@ func Load() (*Config, error) {
k.Set("clipboard_debounce_sec", 3)
k.Set("shortcut_token_ttl_days", 365)
k.Set("shortcut_max_per_user", 10)
k.Set("qr_login_enabled", true)
k.Set("qr_request_ttl_seconds", 120)
k.Set("qr_guest_ttl_seconds", 3600)
k.Set("qr_persist_ttl_hours", 168)
k.Set("session_token_ttl_seconds", 900)
k.Set("account_match_claim", "email")
k.Set("step_up_max_age_seconds", 300)
if _, err := os.Stat("config.yaml"); err == nil {
if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil {
+76
View File
@@ -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
}
+15
View File
@@ -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)
}
+43 -1
View File
@@ -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)
}
}
+9 -3
View File
@@ -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 {
+151
View File
@@ -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
}
+51 -1
View File
@@ -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 续);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);
@@ -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);
-- 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);
+31
View File
@@ -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"`
}
+22
View File
@@ -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 = ?;
+7 -2
View File
@@ -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 < ?;
+39
View File
@@ -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 < ?;
+31 -1
View File
@@ -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 = ?;
+138 -1
View File
@@ -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
}
+117 -17
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -109,12 +110,15 @@ func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
return
}
user, err := extractUser(tr.IDToken, tr.AccessToken)
identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim)
if err != nil {
writeJSON(w, http.StatusBadGateway,
map[string]string{"error": "extract user: " + err.Error()})
return
}
user := identity.User
// Refresh the thin accounts row (display name / avatar / roles / match_key).
s.upsertAccount(r.Context(), identity)
// Stash the refresh_token server-side and hand the browser an opaque,
// HttpOnly cookie instead. Guarded so a deploy without the encryption key (or
@@ -211,6 +215,15 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "session expired"})
return
}
// Self-signed sessions (scan-login: kind self/guest) carry no IdP refresh_token.
// Mint a fresh cdrop access token straight from the row, slide the window for
// persistent sessions, re-arm the cookie — never touching the IdP (AUTH.md §3.1).
if sess.Kind != "oidc" {
s.refreshSelfSession(w, r, sess, c.Value, now)
return
}
refreshToken, err := s.decryptRefresh(sess.RefreshToken)
if err != nil {
// Corrupt ciphertext or rotated key — the session is unusable, drop it.
@@ -239,12 +252,15 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
return
}
user, err := extractUser(tr.IDToken, tr.AccessToken)
identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim)
if err != nil {
writeJSON(w, http.StatusBadGateway,
map[string]string{"error": "extract user: " + err.Error()})
return
}
user := identity.User
// Keep the thin accounts row fresh on every successful refresh too.
s.upsertAccount(r.Context(), identity)
// Rotate: Casdoor issues a new refresh_token on each grant; fall back to the
// existing one if it didn't. Slide the window forward and re-arm the cookie.
@@ -274,6 +290,44 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
})
}
// refreshSelfSession renews a cdrop self-signed session without contacting the
// IdP: it mints a fresh access token from the row. Persistent "self" sessions
// slide their inactivity window; one-time "guest" borrows do not (their fixed
// expires_at caps the borrow at QR_GUEST_TTL). Identity is enriched from the
// accounts row when present, else falls back to the bare user_id.
func (s *Server) refreshSelfSession(w http.ResponseWriter, r *http.Request, sess db.WebSession, rawCookie string, now time.Time) {
token, expiresIn, err := s.mintSessionToken(sess.UserID, sess.ID, sess.Scope)
if err != nil {
slog.Error("mint session token failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"})
return
}
if sess.Kind == "self" {
newExp := now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour)
if err := s.queries.SlideWebSession(r.Context(), db.SlideWebSessionParams{
LastUsedAt: now.Unix(),
ExpiresAt: newExp.Unix(),
ID: sess.ID,
}); err != nil {
slog.Error("slide web session failed", "err", err)
}
setSessionCookie(w, rawCookie)
}
user := userResp{ID: sess.UserID, Name: sess.UserID}
if acct, err := s.queries.GetAccount(r.Context(), sess.UserID); err == nil {
if acct.DisplayName != "" {
user.Name = acct.DisplayName
}
user.Avatar = acct.AvatarUrl
}
writeJSON(w, http.StatusOK, refreshResp{
AccessToken: token,
ExpiresIn: expiresIn,
User: user,
DeviceName: sess.DeviceName,
})
}
var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second}
func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) {
@@ -304,11 +358,22 @@ func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, for
return &tr, nil
}
// extractUser parses {sub, preferred_username | name} from the id_token if
// available, else falls back to access_token. We DON'T verify the signature
// here — the backend's auth middleware verifies access_token on every API
// call via the JWKS cache, so any tamper would surface there.
func extractUser(idToken, accessToken string) (userResp, error) {
// tokenIdentity is what cdrop reads out of an OIDC id_token at login: the display
// identity plus the thin-account fields — match_key (for the admin-gated migration
// relink) and roles (the groups claim cache). AUTH.md §2.1.
type tokenIdentity struct {
User userResp
MatchKey string
Roles []string
}
// extractIdentity parses {sub, preferred_username | name | email, avatar | picture,
// matchClaim, groups} from the id_token if available, else the access_token. The
// signature is NOT verified here — for OIDC sessions the auth middleware verifies
// the access_token on every API call via JWKS, so any tamper surfaces there. (Once
// OIDC sessions migrate to cdrop self-signed access tokens, this becomes the only
// trust point and MUST then verify the id_token signature — AUTH.md §5.)
func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, error) {
pick := idToken
if pick == "" {
pick = accessToken
@@ -319,31 +384,66 @@ func extractUser(idToken, accessToken string) (userResp, error) {
jose.HS256,
})
if err != nil {
return userResp{}, err
return tokenIdentity{}, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil {
return userResp{}, err
return tokenIdentity{}, err
}
u := userResp{ID: std.Subject}
id := tokenIdentity{User: userResp{ID: std.Subject}}
if v, ok := custom["preferred_username"].(string); ok && v != "" {
u.Name = v
id.User.Name = v
} else if v, ok := custom["name"].(string); ok && v != "" {
u.Name = v
id.User.Name = v
} else if v, ok := custom["email"].(string); ok && v != "" {
u.Name = v
id.User.Name = v
} else {
u.Name = u.ID
id.User.Name = id.User.ID
}
// Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段;
// 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。
if v, ok := custom["avatar"].(string); ok && v != "" {
u.Avatar = v
id.User.Avatar = v
} else if v, ok := custom["picture"].(string); ok && v != "" {
u.Avatar = v
id.User.Avatar = v
}
// match_key feeds the admin-gated cross-provider relink; pick the configured
// claim (default email). Absent / non-string → empty, which simply never matches.
if matchClaim != "" {
if v, ok := custom[matchClaim].(string); ok {
id.MatchKey = v
}
}
if g, ok := custom["groups"].([]any); ok {
for _, item := range g {
if r, ok := item.(string); ok && r != "" {
id.Roles = append(id.Roles, r)
}
}
}
return id, nil
}
// upsertAccount refreshes the caller's thin accounts row from their OIDC identity
// (AUTH.md §2.1). Best-effort — a write failure must never fail the login itself.
func (s *Server) upsertAccount(ctx context.Context, id tokenIdentity) {
if id.User.ID == "" {
return
}
now := time.Now().Unix()
if err := s.queries.UpsertAccount(ctx, db.UpsertAccountParams{
UserID: id.User.ID,
MatchKey: id.MatchKey,
DisplayName: id.User.Name,
AvatarUrl: id.User.Avatar,
Roles: strings.Join(id.Roles, ","),
Provider: "oidc",
CreatedAt: now,
LastLoginAt: now,
}); err != nil {
slog.Error("upsert account failed", "err", err, "user", id.User.ID)
}
return u, nil
}
func truncate(s string, n int) string {
+57
View File
@@ -0,0 +1,57 @@
package httpapi
import (
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// extractIdentity pulls the display identity plus the thin-account fields
// (match_key from the configured claim, roles from groups) out of an id_token.
// It does not verify the signature, so a throwaway HS256 key suffices here.
func TestExtractIdentity(t *testing.T) {
key := []byte("any-throwaway-signing-key-32-bytes!!")
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT"))
if err != nil {
t.Fatalf("signer: %v", err)
}
tok, err := jwt.Signed(sig).
Claims(jwt.Claims{Subject: "sub-123", Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour))}).
Claims(map[string]any{
"preferred_username": "alice",
"email": "alice@example.net",
"avatar": "https://a/av.png",
"groups": []any{"admins", "staff"},
}).Serialize()
if err != nil {
t.Fatalf("sign: %v", err)
}
id, err := extractIdentity(tok, "", "email")
if err != nil {
t.Fatalf("extract: %v", err)
}
if id.User.ID != "sub-123" || id.User.Name != "alice" || id.User.Avatar != "https://a/av.png" {
t.Errorf("user wrong: %+v", id.User)
}
if id.MatchKey != "alice@example.net" {
t.Errorf("match_key: got %q, want alice@example.net", id.MatchKey)
}
if len(id.Roles) != 2 || id.Roles[0] != "admins" || id.Roles[1] != "staff" {
t.Errorf("roles: got %v, want [admins staff]", id.Roles)
}
// No name claim → falls back to sub; absent match claim → empty (never matches).
tok2, _ := jwt.Signed(sig).
Claims(jwt.Claims{Subject: "sub-x", Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour))}).
Serialize()
id2, err := extractIdentity(tok2, "", "email")
if err != nil {
t.Fatalf("extract2: %v", err)
}
if id2.User.Name != "sub-x" || id2.MatchKey != "" || len(id2.Roles) != 0 {
t.Errorf("fallback wrong: %+v", id2)
}
}
+474
View File
@@ -0,0 +1,474 @@
package httpapi
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
// Scan-login (QR), AUTH.md §4. A new device shows a QR; an already-logged-in
// phone scans and approves it into the user's account as a restricted guest
// session. Three secrets are kept apart so the QR — which can be photographed —
// only lets someone *request* approval, never collect the session:
//
// - request_id : public handle, in the QR.
// - approval_code : in the QR; binds the authed approver to this request.
// - poll_secret : returned ONCE to the new device, never in the QR; only its
// SHA-256 is stored. The session is delivered solely on the connection that
// proves the plaintext, so a QR photographer (no poll_secret, not logged in)
// fails twice.
//
// The session row is created when the new device collects it (handleQRStatus),
// not at approve time, so the cookie secret is generated on and returned to the
// new device's own TLS connection and never travels through the phone.
// qrStatusPollWindow caps how long one status long-poll blocks before returning
// "pending" for the client to re-poll. var (not const) so tests can shrink it.
var qrStatusPollWindow = 25 * time.Second
type qrStartReq struct {
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"`
}
type qrStartResp struct {
RequestID string `json:"request_id"`
PollSecret string `json:"poll_secret"`
QRPayload string `json:"qr_payload"`
ExpiresAt int64 `json:"expires_at"`
}
type qrStatusResp struct {
Status string `json:"status"` // pending | approved | denied | expired
AccessToken string `json:"access_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
User *userResp `json:"user,omitempty"`
DeviceName string `json:"device_name,omitempty"`
}
type qrRequestResp struct {
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"`
RequestIP string `json:"request_ip"`
ExpiresAt int64 `json:"expires_at"`
Status string `json:"status"`
// StepUp tells the approver UI a fresh re-auth is required before approving.
StepUp bool `json:"step_up"`
}
type qrApproveReq struct {
RequestID string `json:"request_id"`
ApprovalCode string `json:"approval_code"`
Scope string `json:"scope"` // full | guest (this milestone always guest)
Persist string `json:"persist"` // once | persist
// Step-up (when CDROP_STEP_UP_ENABLED): a fresh prompt=login PKCE code+verifier
// the backend exchanges and checks for a recent auth_time (AUTH.md §6).
StepUpCode string `json:"step_up_code"`
StepUpVerifier string `json:"step_up_verifier"`
}
type qrDenyReq struct {
RequestID string `json:"request_id"`
ApprovalCode string `json:"approval_code"`
}
// handleQRStart (public, rate-limited) opens a scan-login request for a new
// device and returns the QR payload plus the device-private poll secret.
func (s *Server) handleQRStart(w http.ResponseWriter, r *http.Request) {
if !s.cfg.QRLoginOn() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"})
return
}
var req qrStartReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
deviceName := jwtauth.SanitizeDeviceName(req.DeviceName)
if deviceName == "" {
deviceName = "New device"
}
requestID, err1 := randB64(16)
approvalCode, err2 := randB64(9)
pollSecret, err3 := randB64(32)
if err := errors.Join(err1, err2, err3); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
return
}
now := time.Now()
expires := now.Add(time.Duration(s.cfg.QRRequestTTLSeconds) * time.Second)
if err := s.queries.CreateLoginRequest(r.Context(), db.CreateLoginRequestParams{
ID: requestID,
PollSecret: sessionID(pollSecret), // store only the hash
ApprovalCode: approvalCode,
NewDeviceName: deviceName,
NewDeviceType: qrDeviceType(req.DeviceType),
UserAgent: truncate(r.UserAgent(), 256),
RequestIp: clientIP(r),
CreatedAt: now.Unix(),
ExpiresAt: expires.Unix(),
}); err != nil {
slog.Error("create login request failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
writeJSON(w, http.StatusOK, qrStartResp{
RequestID: requestID,
PollSecret: pollSecret,
QRPayload: s.qrPayload(r, requestID, approvalCode),
ExpiresAt: expires.Unix(),
})
}
// handleQRStatus (public, rate-limited) is the new device's long-poll. It proves
// the poll_secret, then reports pending until the request is approved/denied/
// expired. On approval it creates the session, sets the cookie on THIS response,
// mints the first access token, and marks the request consumed (single use).
func (s *Server) handleQRStatus(w http.ResponseWriter, r *http.Request) {
if !s.cfg.QRLoginOn() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"})
return
}
requestID := r.URL.Query().Get("request_id")
pollSecret := r.Header.Get("X-Poll-Secret")
if requestID == "" || pollSecret == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing request_id or poll secret"})
return
}
pollHash := sessionID(pollSecret)
deadline := time.Now().Add(qrStatusPollWindow)
for {
req, err := s.queries.GetLoginRequest(r.Context(), requestID)
if err != nil {
// Unknown / reaped → treat as expired without leaking existence.
writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"})
return
}
if subtle.ConstantTimeCompare([]byte(req.PollSecret), []byte(pollHash)) != 1 {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad poll secret"})
return
}
if req.ExpiresAt < time.Now().Unix() {
writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"})
return
}
switch req.Status {
case "approved":
s.collectQRSession(w, r, req)
return
case "denied":
writeJSON(w, http.StatusOK, qrStatusResp{Status: "denied"})
return
case "consumed":
writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"})
return
default: // pending
if time.Now().After(deadline) {
writeJSON(w, http.StatusOK, qrStatusResp{Status: "pending"})
return
}
select {
case <-r.Context().Done():
return
case <-time.After(time.Second):
}
}
}
}
// collectQRSession turns an approved request into a live session for the new
// device. ConsumeLoginRequest is the single-use guard: only the poll that flips
// approved→consumed proceeds, so a duplicated/raced poll can't mint a second
// session.
func (s *Server) collectQRSession(w http.ResponseWriter, r *http.Request, req db.LoginRequest) {
n, err := s.queries.ConsumeLoginRequest(r.Context(), req.ID)
if err != nil {
slog.Error("consume login request failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if n == 0 {
// Lost the race to another poll; the winner already has the session.
writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"})
return
}
raw, id, err := newSessionToken()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
return
}
scope := req.GrantScope
if scope != "full" {
scope = "guest"
}
// Persistent borrows slide their window (kind self); one-time borrows are kind
// guest with a fixed short expiry that refresh never extends (AUTH.md §3.1).
now := time.Now()
kind := "guest"
exp := now.Add(time.Duration(s.cfg.QRGuestTTLSeconds) * time.Second)
if req.GrantPersist == "persist" {
kind = "self"
exp = now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour)
}
if err := s.queries.CreateSelfSession(r.Context(), db.CreateSelfSessionParams{
ID: id,
UserID: req.ApproverUserID,
DeviceName: req.NewDeviceName,
UserAgent: req.UserAgent,
Kind: kind,
Scope: scope,
GrantedBy: "qr",
CreatedAt: now.Unix(),
LastUsedAt: now.Unix(),
ExpiresAt: exp.Unix(),
}); err != nil {
slog.Error("create self session failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
token, expiresIn, err := s.mintSessionToken(req.ApproverUserID, id, scope)
if err != nil {
slog.Error("mint session token failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"})
return
}
setSessionCookie(w, raw)
user := userResp{ID: req.ApproverUserID, Name: req.ApproverUserID}
if acct, err := s.queries.GetAccount(r.Context(), req.ApproverUserID); err == nil {
if acct.DisplayName != "" {
user.Name = acct.DisplayName
}
user.Avatar = acct.AvatarUrl
}
writeJSON(w, http.StatusOK, qrStatusResp{
Status: "approved",
AccessToken: token,
ExpiresIn: expiresIn,
User: &user,
DeviceName: req.NewDeviceName,
})
}
// handleQRRequest (full session) lets the approver see what they are about to
// authorise. The approval_code (from the QR) gates the lookup, so only someone
// who scanned the code can read the request's device info.
func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) {
if !s.cfg.QRLoginOn() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"})
return
}
req, ok := s.lookupQRRequest(w, r, r.URL.Query().Get("request_id"), r.URL.Query().Get("code"))
if !ok {
return
}
writeJSON(w, http.StatusOK, qrRequestResp{
DeviceName: req.NewDeviceName,
DeviceType: req.NewDeviceType,
RequestIP: req.RequestIp,
ExpiresAt: req.ExpiresAt,
Status: req.Status,
StepUp: s.cfg.StepUpEnabled,
})
}
// handleQRApprove (full session) binds the request to the caller and records the
// granted scope/persistence. The new device's poll then collects the session.
func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) {
if !s.cfg.QRLoginOn() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"})
return
}
var body qrApproveReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
req, ok := s.lookupQRRequest(w, r, body.RequestID, body.ApprovalCode)
if !ok {
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
// Step-up: approving a new device into your account is sensitive. When enabled,
// require a fresh prompt=login re-auth (the provider's 2FA, if configured, is
// enforced during that exchange) within the freshness window (AUTH.md §6).
if s.cfg.StepUpEnabled && !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
return
}
scope := "guest" // this milestone grants restricted guest sessions only
if body.Scope == "full" {
scope = "full"
}
persist := "once"
if body.Persist == "persist" {
persist = "persist"
}
now := time.Now()
n, err := s.queries.ApproveLoginRequest(r.Context(), db.ApproveLoginRequestParams{
ApproverUserID: claims.UserID,
GrantScope: scope,
GrantPersist: persist,
ApprovedAt: ptrInt64(now.Unix()),
ID: req.ID,
})
if err != nil {
slog.Error("approve login request failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if n == 0 {
// Raced to denied/consumed/expired between lookup and update.
writeJSON(w, http.StatusConflict, map[string]string{"error": "request no longer pending"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleQRDeny (full session) rejects a pending request.
func (s *Server) handleQRDeny(w http.ResponseWriter, r *http.Request) {
if !s.cfg.QRLoginOn() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"})
return
}
var body qrDenyReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
req, ok := s.lookupQRRequest(w, r, body.RequestID, body.ApprovalCode)
if !ok {
return
}
if _, err := s.queries.DenyLoginRequest(r.Context(), req.ID); err != nil {
slog.Error("deny login request failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// verifyStepUp exchanges a fresh prompt=login authorization code and confirms it
// proves a recent interactive re-authentication by the same user: the id_token's
// signature validates (JWKS), its sub matches the caller, and its auth_time is
// within StepUpMaxAgeSeconds. Any failure → false (the caller answers 403). The
// code is single-use at the IdP, so it can't be replayed (AUTH.md §6).
func (s *Server) verifyStepUp(r *http.Request, userID, code, verifier string) bool {
if code == "" || verifier == "" || s.cfg.OIDCTokenURL == "" {
return false
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("code_verifier", verifier)
form.Set("client_id", s.cfg.OIDCClientID)
form.Set("redirect_uri", s.cfg.OIDCRedirectURI)
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
slog.Warn("step-up exchange failed", "err", err)
return false
}
if tr.IDToken == "" {
return false
}
sub, authTime, err := s.auth.VerifyIDToken(r.Context(), tr.IDToken)
if err != nil {
slog.Warn("step-up id_token invalid", "err", err)
return false
}
if sub != userID {
return false
}
maxAge := int64(s.cfg.StepUpMaxAgeSeconds)
if maxAge <= 0 {
maxAge = 300
}
return time.Now().Unix()-authTime <= maxAge
}
// lookupQRRequest fetches a request and verifies the approval_code in constant
// time. It writes the error response itself and returns ok=false on any failure
// (missing args, unknown request, bad code, expired), so callers just early-return.
func (s *Server) lookupQRRequest(w http.ResponseWriter, r *http.Request, requestID, code string) (db.LoginRequest, bool) {
if requestID == "" || code == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing request_id or code"})
return db.LoginRequest{}, false
}
req, err := s.queries.GetLoginRequest(r.Context(), requestID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "request not found"})
return db.LoginRequest{}, false
}
if subtle.ConstantTimeCompare([]byte(req.ApprovalCode), []byte(code)) != 1 {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad approval code"})
return db.LoginRequest{}, false
}
if req.ExpiresAt < time.Now().Unix() {
writeJSON(w, http.StatusGone, map[string]string{"error": "request expired"})
return db.LoginRequest{}, false
}
return req, true
}
// qrPayload builds the URL encoded into the QR: the approver opens it on their
// phone. Prefers the configured site origin; falls back to the request's own
// scheme/host for dev.
func (s *Server) qrPayload(r *http.Request, requestID, approvalCode string) string {
origin := s.siteOrigin
if origin == "" {
scheme := "https"
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" {
scheme = "http"
}
origin = scheme + "://" + r.Host
}
return origin + "/link?r=" + requestID + "&c=" + approvalCode
}
func qrDeviceType(raw string) string {
switch t := strings.ToLower(strings.TrimSpace(raw)); t {
case "macos", "windows", "linux", "ios", "browser":
return t
default:
return "browser"
}
}
func clientIP(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
func ptrInt64(v int64) *int64 { return &v }
func randB64(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
+243
View File
@@ -0,0 +1,243 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
const qrTestSecret = "qr-test-session-secret-at-least-32-bytes"
// newQRTestServer builds a Server with only the fields the scan-login handlers
// touch, backed by a fresh file-based sqlite (an in-memory DB would give each
// pooled connection its own empty schema).
func newQRTestServer(t *testing.T) *Server {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "qr.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
return &Server{
cfg: &config.Config{
AuthMode: "prod", SessionSecret: qrTestSecret,
QRLoginEnabled: true, QRRequestTTLSeconds: 120,
QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168,
SessionTokenTTLSeconds: 900,
},
queries: db.New(conn),
sessionKey: deriveSessionKey(qrTestSecret),
sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret),
siteOrigin: "https://drop.example.net",
}
}
func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) {
t.Helper()
body := fmt.Sprintf(`{"device_name":%q,"device_type":"browser"}`, deviceName)
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/start", strings.NewReader(body))
w := httptest.NewRecorder()
s.handleQRStart(w, r)
if w.Code != http.StatusOK {
t.Fatalf("qr/start: %d %s", w.Code, w.Body.String())
}
var resp qrStartResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode start: %v", err)
}
u, err := url.Parse(resp.QRPayload)
if err != nil {
t.Fatalf("qr_payload not a url: %v", err)
}
return resp, u.Query().Get("c")
}
func qrApprove(t *testing.T, s *Server, requestID, code, persist, approver string) int {
t.Helper()
body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":"guest","persist":%q}`, requestID, code, persist)
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, SessionScope: "full"}))
w := httptest.NewRecorder()
s.handleQRApprove(w, r)
return w.Code
}
func qrStatus(s *Server, requestID, pollSecret string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, "/api/auth/qr/status?request_id="+url.QueryEscape(requestID), nil)
r.Header.Set("X-Poll-Secret", pollSecret)
w := httptest.NewRecorder()
s.handleQRStatus(w, r)
return w
}
func decodeStatus(t *testing.T, w *httptest.ResponseRecorder) qrStatusResp {
t.Helper()
var resp qrStatusResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode status: %v (%s)", err, w.Body.String())
}
return resp
}
// The happy path: a new device starts a request, the approver authorises it as a
// one-time guest, and the new device collects a live guest session — cookie set,
// access token minted, request single-use thereafter.
func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) {
s := newQRTestServer(t)
start, code := qrStart(t, s, "Borrowed Laptop")
if c := qrApprove(t, s, start.RequestID, code, "once", "approver-1"); c != http.StatusNoContent {
t.Fatalf("approve: got %d, want 204", c)
}
w := qrStatus(s, start.RequestID, start.PollSecret)
if w.Code != http.StatusOK {
t.Fatalf("status: %d %s", w.Code, w.Body.String())
}
got := decodeStatus(t, w)
if got.Status != "approved" || got.AccessToken == "" || got.DeviceName != "Borrowed Laptop" {
t.Fatalf("collected status wrong: %+v", got)
}
if got.ExpiresIn != 900 {
t.Errorf("expires_in: got %d, want 900", got.ExpiresIn)
}
var hasCookie bool
for _, ck := range w.Result().Cookies() {
if ck.Name == sessionCookieName && ck.Value != "" {
hasCookie = true
}
}
if !hasCookie {
t.Error("collection must set the session cookie on the new device's response")
}
// The minted token is a properly signed guest session token.
if scope := selfTokenScope(t, got.AccessToken); scope != "guest" {
t.Fatalf("minted token scope: got %q, want guest", scope)
}
// A guest (one-time) session row exists for the approver and is non-sliding.
rows, err := s.queries.ListWebSessionsByUser(context.Background(), "approver-1")
if err != nil || len(rows) != 1 {
t.Fatalf("expected one session row, got %d (err=%v)", len(rows), err)
}
if rows[0].Kind != "guest" || rows[0].Scope != "guest" {
t.Errorf("session kind/scope: got %s/%s, want guest/guest", rows[0].Kind, rows[0].Scope)
}
// Single use: a second collection finds the request consumed.
if got2 := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got2.Status != "expired" {
t.Errorf("second collection: got %q, want expired (consumed)", got2.Status)
}
}
// A persistent ("trust this device") approval yields a sliding self session.
func TestQRFlow_PersistYieldsSelfSession(t *testing.T) {
s := newQRTestServer(t)
start, code := qrStart(t, s, "Home PC")
if c := qrApprove(t, s, start.RequestID, code, "persist", "approver-2"); c != http.StatusNoContent {
t.Fatalf("approve: got %d, want 204", c)
}
if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" {
t.Fatalf("collect: %+v", got)
}
rows, _ := s.queries.ListWebSessionsByUser(context.Background(), "approver-2")
if len(rows) != 1 || rows[0].Kind != "self" {
t.Fatalf("persistent approval must create a kind=self session, got %+v", rows)
}
}
// The poll_secret is the new device's only credential: a wrong one is rejected
// even for a real, approved request, so a QR photographer can't collect it.
func TestQRFlow_WrongPollSecretRejected(t *testing.T) {
s := newQRTestServer(t)
start, code := qrStart(t, s, "Laptop")
_ = qrApprove(t, s, start.RequestID, code, "once", "approver-3")
if w := qrStatus(s, start.RequestID, "not-the-secret"); w.Code != http.StatusForbidden {
t.Errorf("wrong poll secret: got %d, want 403", w.Code)
}
// The real secret still works afterwards (the bad attempt didn't consume it).
if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" {
t.Errorf("real secret after a bad attempt: got %q, want approved", got.Status)
}
}
// Denial propagates to the polling device.
func TestQRFlow_Deny(t *testing.T) {
s := newQRTestServer(t)
start, code := qrStart(t, s, "Laptop")
body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q}`, start.RequestID, code)
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/deny", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "approver-4", SessionScope: "full"}))
w := httptest.NewRecorder()
s.handleQRDeny(w, r)
if w.Code != http.StatusNoContent {
t.Fatalf("deny: got %d, want 204", w.Code)
}
if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "denied" {
t.Errorf("status after deny: got %q, want denied", got.Status)
}
}
// With step-up enabled, approving without a fresh re-auth is refused (the gate
// rejects an empty step-up proof before any IdP call). AUTH.md §6.
func TestQRFlow_StepUpRequiredRejectsWithoutReauth(t *testing.T) {
s := newQRTestServer(t)
s.cfg.StepUpEnabled = true
start, code := qrStart(t, s, "Laptop")
if c := qrApprove(t, s, start.RequestID, code, "once", "approver-su"); c != http.StatusForbidden {
t.Errorf("approve without step-up proof: got %d, want 403", c)
}
}
// A pending request long-polls then reports pending for the client to re-poll.
func TestQRFlow_PendingLongPoll(t *testing.T) {
saved := qrStatusPollWindow
qrStatusPollWindow = 50 * time.Millisecond
defer func() { qrStatusPollWindow = saved }()
s := newQRTestServer(t)
start, _ := qrStart(t, s, "Laptop")
w := qrStatus(s, start.RequestID, start.PollSecret)
if got := decodeStatus(t, w); got.Status != "pending" {
t.Errorf("pending long-poll: got %q, want pending", got.Status)
}
}
// selfTokenScope verifies a self-signed session token under the test's session
// key (proving the signature) and returns its scope claim.
func selfTokenScope(t *testing.T, token string) string {
t.Helper()
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
t.Fatalf("parse token: %v", err)
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(jwtauth.DeriveSessionTokenKey(qrTestSecret), &std, &custom); err != nil {
t.Fatalf("verify token signature: %v", err)
}
if typ, _ := custom["typ"].(string); typ != "session" {
t.Fatalf("token typ: got %q, want session", typ)
}
scope, _ := custom["scope"].(string)
return scope
}
+39
View File
@@ -0,0 +1,39 @@
package httpapi
import (
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// mintSessionToken signs a short-lived cdrop self-signed session access token
// (AUTH.md §3.1): HS256 over the SessionSecret-derived key, sub=userID, sid=the
// web_sessions row id, typ=session, scope=full|guest. The matching verifier is
// jwtauth.verifySelfToken. Returns the token and its lifetime in seconds (for the
// client's expires_in). Used by scan-login and self/guest session refresh — never
// touches the IdP.
func (s *Server) mintSessionToken(userID, sid, scope string) (string, int, error) {
ttl := time.Duration(s.cfg.SessionTokenTTLSeconds) * time.Second
now := time.Now()
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: s.sessionTokenKey},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
return "", 0, err
}
std := jwt.Claims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(ttl)),
}
tok, err := jwt.Signed(sig).
Claims(std).
Claims(map[string]any{"typ": "session", "scope": scope, "sid": sid}).
Serialize()
if err != nil {
return "", 0, err
}
return tok, int(ttl / time.Second), nil
}
+32 -6
View File
@@ -44,6 +44,11 @@ type Server struct {
sessionKey []byte
siteOrigin string
// sessionTokenKey signs cdrop's self-signed session access tokens (scan-login;
// AUTH.md §3.1), derived from SessionSecret in a separate domain from sessionKey.
// nil when SessionSecret is unset → minting is unavailable (QR stays off).
sessionTokenKey []byte
// refreshLocks serialise concurrent refreshes of the same web session (all of
// a user's browser tabs share one cookie → one refresh_token). Striped so the
// lock set stays bounded; collisions just serialise unrelated sessions, which
@@ -77,8 +82,9 @@ func New(
push: pushSender,
mux: chi.NewRouter(),
sessionKey: deriveSessionKey(cfg.SessionSecret),
siteOrigin: deriveSiteOrigin(cfg.OIDCRedirectURI),
sessionKey: deriveSessionKey(cfg.SessionSecret),
siteOrigin: deriveSiteOrigin(cfg.OIDCRedirectURI),
sessionTokenKey: jwtauth.DeriveSessionTokenKey(cfg.SessionSecret),
}
s.routes()
return s
@@ -114,6 +120,11 @@ func (s *Server) routes() {
// this browser's name into the session for PWA-eviction recovery.
r.Post("/auth/logout", s.handleAuthLogout)
r.Post("/auth/device", s.handleAuthDevice)
// Scan-login: the new device opens a request and long-polls status.
// Public (no bearer — the device isn't logged in yet); the private
// poll_secret in the X-Poll-Secret header is the only credential.
r.Post("/auth/qr/start", s.handleQRStart)
r.Get("/auth/qr/status", s.handleQRStatus)
})
// Protected routes. gzip / compress is intentionally NOT mounted —
@@ -144,14 +155,29 @@ func (s *Server) routes() {
r.Post("/hub/signal", s.handleSignal)
r.Post("/message", s.handleMessage)
r.Get("/devices", s.handleDevices)
r.Delete("/devices/{name}", s.handleDeleteDevice)
r.Get("/push/vapid-key", s.handlePushVAPIDKey)
r.Post("/push/subscribe", s.handlePushSubscribe)
r.Delete("/push/subscribe", s.handlePushUnsubscribe)
r.Get("/calls/credentials", s.handleCallsCredentials)
r.Post("/shortcut/issue", s.handleShortcutIssue)
r.Get("/shortcut", s.handleShortcutList)
r.Delete("/shortcut/{jti}", s.handleShortcutRevoke)
// Account-management surface: restricted guest (scan-login borrow)
// sessions are rejected here too — they can transfer files but not
// remove devices, nor mint / list / revoke long-lived shortcut
// tokens. Full / OIDC / dev sessions pass (AUTH.md §3.2).
r.Group(func(r chi.Router) {
r.Use(requireFullSession)
r.Delete("/devices/{name}", s.handleDeleteDevice)
r.Post("/shortcut/issue", s.handleShortcutIssue)
r.Get("/shortcut", s.handleShortcutList)
r.Delete("/shortcut/{jti}", s.handleShortcutRevoke)
// Scan-login approval side: the logged-in approver views and
// authorises the new device. Guest sessions can't reach here, so a
// borrowed device can't approve further devices.
r.Get("/auth/qr/request", s.handleQRRequest)
r.Post("/auth/qr/approve", s.handleQRApprove)
r.Post("/auth/qr/deny", s.handleQRDeny)
})
r.Route("/transfer", func(r chi.Router) {
r.Post("/initiate", s.handleTransferInit)
r.Post("/{id}/accept", s.transitionHandler(transfer.StateAccepted, ""))
+9 -1
View File
@@ -249,12 +249,20 @@ func RunWebSessionReaper(ctx context.Context, q *db.Queries) {
case <-ctx.Done():
return
case <-ticker.C:
n, err := q.DeleteExpiredWebSessions(ctx, time.Now().Unix())
now := time.Now().Unix()
n, err := q.DeleteExpiredWebSessions(ctx, now)
if err != nil {
slog.Warn("web session reaper failed", "err", err)
} else if n > 0 {
slog.Info("web sessions reaped", "count", n)
}
// Scan-login requests are short-lived (default 120s); sweep the
// stragglers here too. Expired rows are already rejected at use time.
if n, err := q.DeleteExpiredLoginRequests(ctx, now); err != nil {
slog.Warn("login request reaper failed", "err", err)
} else if n > 0 {
slog.Info("login requests reaped", "count", n)
}
}
}
}
+20
View File
@@ -59,6 +59,26 @@ func rejectScoped(next http.Handler) http.Handler {
})
}
// requireFullSession rejects restricted guest sessions (scan-login borrow): they
// may transfer files but not reach account-management surfaces — removing devices,
// approving more devices, or minting / listing / revoking long-lived tokens. A
// borrowed device thus can't escalate. Full / OIDC / dev sessions pass; scoped
// shortcut tokens are already blocked upstream by rejectScoped (AUTH.md §3.2).
func requireFullSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
return
}
if claims.Guest() {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"})
return
}
next.ServeHTTP(w, r)
})
}
// 这些端点只由登录会话调用(路由层 rejectScoped 守门)——用 token 不能自管 token
// 防止泄漏的快捷指令 token 自我续期或越权。
+17
View File
@@ -11,12 +11,22 @@ type Claims struct {
// enforces this). This keeps a leaked shortcut token's blast radius minimal.
JTI string
Scopes []string
// SessionScope is set only for cdrop self-signed session tokens (scan-login):
// "full" or "guest". Empty for OIDC / dev sessions and scoped shortcut tokens.
// A "guest" session is capability-limited (requireFullSession rejects it on
// account-management routes) even though it is not Scoped().
SessionScope string
}
// Scoped reports whether these claims came from a scoped shortcut token rather
// than a full login session.
func (c *Claims) Scoped() bool { return c.JTI != "" }
// Guest reports whether these claims came from a restricted guest session (a
// scan-login borrow). Guest sessions can transfer files but not manage the
// account, approve other devices, or mint long-lived tokens.
func (c *Claims) Guest() bool { return c.SessionScope == "guest" }
// HasScope reports whether the claims grant the named scope.
func (c *Claims) HasScope(scope string) bool {
for _, s := range c.Scopes {
@@ -40,6 +50,13 @@ func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
return c, ok
}
// ContextWithClaims attaches claims to a context — the inverse of
// ClaimsFromContext. The auth middleware uses this; it is also the seam handlers
// and tests use to inject claims directly.
func ContextWithClaims(ctx context.Context, c *Claims) context.Context {
return context.WithValue(ctx, claimsCtxKey, c)
}
func DeviceNameFromContext(ctx context.Context) (string, bool) {
n, ok := ctx.Value(deviceCtxKey).(string)
return n, ok
+121 -4
View File
@@ -29,10 +29,11 @@ type Store interface {
}
type Authenticator struct {
cfg *config.Config
store Store
jwks *jwksCache
hsKey []byte
cfg *config.Config
store Store
jwks *jwksCache
hsKey []byte
sessionTokenKey []byte
}
func New(cfg *config.Config, store Store) *Authenticator {
@@ -43,6 +44,9 @@ func New(cfg *config.Config, store Store) *Authenticator {
if cfg.HS256Secret != "" {
a.hsKey = DeriveHS256Key(cfg.HS256Secret)
}
// Self-signed session tokens (scan-login) are keyed off SessionSecret; nil
// when unset (dev) disables verifySelfToken, matching the minting side.
a.sessionTokenKey = DeriveSessionTokenKey(cfg.SessionSecret)
if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" {
a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute)
}
@@ -101,12 +105,51 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques
if a.cfg.AuthMode == "dev" {
return a.verifyDev(token, r)
}
// cdrop self-signed session tokens first: distinct key + typ=session, so a
// shortcut token (different key, requires jti) never validates here and a
// session token never falls through to the shortcut path's DB lookup.
if c, err := a.verifySelfToken(token); err == nil {
return c, nil
}
if c, err := a.verifyHS256(ctx, token); err == nil {
return c, nil
}
return a.verifyRS256(ctx, token)
}
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
// scope. Stateless by design — no DB lookup, so it stays cheap on every request;
// revocation rides the short TTL (the session row is re-checked at /auth/refresh).
func (a *Authenticator) verifySelfToken(token string) (*Claims, error) {
if len(a.sessionTokenKey) == 0 {
return nil, errors.New("session token key not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(a.sessionTokenKey, &std, &custom); err != nil {
return nil, err
}
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
return nil, err
}
if typ, _ := custom["typ"].(string); typ != "session" {
return nil, errors.New("not a session token")
}
if std.Subject == "" {
return nil, errors.New("session token missing subject")
}
scope, _ := custom["scope"].(string)
if scope != "full" && scope != "guest" {
return nil, errors.New("session token invalid scope")
}
return &Claims{UserID: std.Subject, SessionScope: scope}, nil
}
func (a *Authenticator) verifyDev(token string, r *http.Request) (*Claims, error) {
if subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
return nil, errors.New("invalid dev token")
@@ -220,6 +263,66 @@ func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims,
return claimsFromJWT(std, custom)
}
// VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience)
// from a fresh prompt=login exchange and returns its subject and auth_time (the
// epoch second of the actual end-user authentication). Used for step-up re-auth
// (AUTH.md §6): the caller checks sub matches and auth_time is recent enough.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) {
if a.jwks == nil {
return "", 0, errors.New("JWKS not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
if err != nil {
return "", 0, err
}
if len(parsed.Headers) == 0 {
return "", 0, errors.New("missing token headers")
}
key, err := a.jwks.GetKey(ctx, parsed.Headers[0].KeyID)
if err != nil {
return "", 0, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(key, &std, &custom); err != nil {
return "", 0, err
}
expected := jwt.Expected{Time: time.Now()}
if a.cfg.OIDCIssuer != "" {
expected.Issuer = a.cfg.OIDCIssuer
}
if a.cfg.OIDCAudience != "" {
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
}
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
return "", 0, err
}
if std.Subject == "" {
return "", 0, errors.New("missing subject claim")
}
at, ok := numericClaim(custom["auth_time"])
if !ok {
return "", 0, errors.New("missing auth_time claim")
}
return std.Subject, at, nil
}
// numericClaim coerces a JSON number claim (float64 from stdlib unmarshal, or
// json.Number / int64) to int64.
func numericClaim(v any) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case int64:
return n, true
case json.Number:
if i, err := n.Int64(); err == nil {
return i, true
}
}
return 0, false
}
// parseAudiences splits a comma-separated OIDCAudience config into a jwt.Audience
// set. Multiple values let one backend accept tokens minted for several OAuth
// clients (the web app and the desktop client carry different `aud`); go-jose's
@@ -284,6 +387,20 @@ func DeriveHS256Key(secret string) []byte {
return sum[:]
}
// DeriveSessionTokenKey derives the HMAC key for cdrop's self-signed session
// access tokens from CDROP_SESSION_SECRET. Domain-separated from both the at-rest
// refresh-token AES key (plain sha256(secret), in httpapi) and the shortcut-token
// key (DeriveHS256Key) so one secret yields three independent keys — a session
// token can never validate as a shortcut token, or vice versa. Empty secret →
// nil, which disables both minting and verifying (dev / unconfigured prod).
func DeriveSessionTokenKey(secret string) []byte {
if secret == "" {
return nil
}
sum := sha256.Sum256([]byte("cdrop-session-jwt\x00" + secret))
return sum[:]
}
// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device
// names ride in the X-Device-Name HTTP header, which can't carry non-ASCII
// reliably (and browser fetch rejects such header values outright), so the name
+77
View File
@@ -488,3 +488,80 @@ func TestSanitizeDeviceName(t *testing.T) {
}
}
}
// signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken
// does: HS256 over DeriveSessionTokenKey, with typ + scope custom claims and no jti.
func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time) string {
t.Helper()
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: DeriveSessionTokenKey(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
t.Fatalf("signer: %v", err)
}
std := jwt.Claims{
Subject: sub,
IssuedAt: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(exp),
}
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope}).Serialize()
if err != nil {
t.Fatalf("serialize: %v", err)
}
return tok
}
// A cdrop self-signed session token authenticates as a full or guest session;
// guest is capability-limited (Guest() true). A wrong typ or an unknown scope is
// rejected, and the SessionSecret-derived key is domain-isolated from the HS256
// shortcut key so the two token families never cross-validate (AUTH.md §3.1).
func TestSelfToken_ScopeAndKeyIsolation(t *testing.T) {
secret := "test-session-secret-at-least-32-bytes-long"
a := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{})
exp := time.Now().Add(time.Hour)
// Full session: authenticates, not scoped, not guest.
code, claims := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "full", exp))
if code != http.StatusOK {
t.Fatalf("full session token: got %d, want 200", code)
}
if claims == nil || claims.UserID != "user-x" || claims.SessionScope != "full" || claims.Scoped() || claims.Guest() {
t.Fatalf("full session claims wrong: %+v", claims)
}
// Guest session: authenticates and is marked guest (capability-limited).
code, claims = serveBearer(a, signSelfToken(t, secret, "user-x", "session", "guest", exp))
if code != http.StatusOK || claims == nil || !claims.Guest() || claims.Scoped() {
t.Fatalf("guest session: code=%d claims=%+v", code, claims)
}
// Wrong typ (not "session") signed with the session key → rejected.
if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "other", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("wrong typ: got %d, want 401", c)
}
// Unknown scope → rejected (no privilege-by-typo).
if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "admin", exp)); c != http.StatusUnauthorized {
t.Errorf("invalid scope: got %d, want 401", c)
}
// Key-domain isolation: with BOTH secrets set, the two families stay disjoint —
// a session token never becomes scoped, a shortcut token never becomes a session.
hsSecret := "test-hs256-secret-at-least-32-bytes-long"
store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{
"jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()},
}}
ab := New(&config.Config{AuthMode: "prod", SessionSecret: secret, HS256Secret: hsSecret}, store)
if _, sc := serveBearer(ab, signSelfToken(t, secret, "user-x", "session", "guest", exp)); sc == nil || sc.Scoped() || !sc.Guest() {
t.Errorf("session token leaked into scoped path: %+v", sc)
}
if _, hc := serveBearer(ab, signHS256(t, hsSecret, "user-x", "jti-1", "clipboard", exp)); hc == nil || !hc.Scoped() || hc.SessionScope != "" {
t.Errorf("shortcut token leaked into session path: %+v", hc)
}
// No SessionSecret on the server → self tokens are unverifiable (key nil).
noSecret := New(&config.Config{AuthMode: "prod", HS256Secret: hsSecret}, &fakeDeviceUpserter{})
if c, _ := serveBearer(noSecret, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("self token without server SessionSecret must be rejected, got %d", c)
}
}