90a3790a98
身份仍源自 OAuth provider(user_id = OIDC sub),cdrop 在其上自维护一薄层: 自签会话令牌 + accounts 表,并新增扫码快速登录。实施蓝本见 AUTH.md。 后端 · 自签会话原语 - web_sessions 加 kind/scope/granted_by 列(bootstrap 幂等迁移);既有 oidc 会话行为不变,新增 self(滑动续期)/ guest(受限·不续)两类 - cdrop 自签 HS256 会话 access token,密钥派生自 SESSION_SECRET,与落盘 AES、 shortcut HS256 三密钥域隔离;jwtauth.verifySelfToken 无状态校验、靠 typ 区分 - requireFullSession 守卫:guest 会话不得改账号 / 再批准设备 / 签长效 token - /auth/refresh 按 kind 分流:self/guest 纯自签、不触 IdP 后端 · 扫码登录(internal/httpapi/qr.go) - qr/start・status・request・approve・deny 五端点 + login_requests 表 + reaper - 三密钥分离:QR 仅含批准信息,会话只投递给持私有 poll_secret 的原设备 (偷拍 QR 者无 poll_secret 领不到会话、未登录批不了准) - 会话在新设备侧领取(cookie 不经手机)、单次消费、短 TTL 后端 · 薄账户层与 2FA step-up - accounts 表(键 sub,不含任何凭证):exchange/refresh upsert match_key / 显示名 / 头像 / roles,供显示与未来管理员开启迁移标记时跨源关联 - step-up(默认关):开启后 qr/approve 要求新鲜 prompt=login 授权码,后端就地 换 id_token、JWKS 验签 + auth_time 窗口 + sub 匹配,provider 2FA 于此往返强制 前端(web/) - 显码页 /link/new + 批准页 /link + net/qr.ts,对齐 Theme B、复用 AuthShell 与聚珍排版管线、零新全局样式 - step-up 再认证流:批准前跑 prompt=login PKCE,回调分叉(不消费一次性 code、 独立 state key)后带 step_up_code/verifier 调 approve - 三语 i18n qr.*;新增 qrcode 依赖 修复 · clipboard sweeper(早已提交的损坏) - ClearExpiredClipboards 因 clipboard.sql 全角注释触发 sqlc 1.31 多字节偏移 bug,生成 SQL 被截断为「UPDATE clipboard_state SET content =」,sweeper 运行 期报「incomplete input」、过期剪贴板内容从未清除(短 TTL 暴露保护失效) - 注释改纯 ASCII 并加 bug 警告,重生成得完整 SQL;prod 已验证 sweeper 由 ERROR 转为正常清理(cleared count=1) 构建 - .dockerignore:排除本地 node_modules 等,避免宿主原生二进制污染镜像内 vite 构建 - Dockerfile.base:GOPROXY 改为可经 --build-arg 覆盖(默认仍官方代理,受限网络 构建时传区域镜像即可,仓库不固化区域值) 文档 - 新增 AUTH.md(账户与登录实施蓝本);README 特性;.env.example / compose.snippet 增配置项(QR / 自签会话 TTL / step-up / match_claim) 测试 - 自签 token 密钥域隔离、扫码端到端(领取 / 单次 / poll_secret 校验 / deny / step-up 门)、extractIdentity、web_sessions 升级迁移
244 lines
8.8 KiB
Go
244 lines
8.8 KiB
Go
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
|
|
}
|