Files
Commilitia-Drop/internal/httpapi/shortcut.go
T
admin 90a3790a98 扫码登录:cdrop 自签会话原语 + 薄账户层 + 受限访客 + 2FA step-up
身份仍源自 OAuth provider(user_id = OIDC sub),cdrop 在其上自维护一薄层:
自签会话令牌 + accounts 表,并新增扫码快速登录。实施蓝本见 AUTH.md。

后端 · 自签会话原语
- web_sessions 加 kind/scope/granted_by 列(bootstrap 幂等迁移);既有 oidc
  会话行为不变,新增 self(滑动续期)/ guest(受限·不续)两类
- cdrop 自签 HS256 会话 access token,密钥派生自 SESSION_SECRET,与落盘 AES、
  shortcut HS256 三密钥域隔离;jwtauth.verifySelfToken 无状态校验、靠 typ 区分
- requireFullSession 守卫:guest 会话不得改账号 / 再批准设备 / 签长效 token
- /auth/refresh 按 kind 分流:self/guest 纯自签、不触 IdP

后端 · 扫码登录(internal/httpapi/qr.go)
- qr/start・status・request・approve・deny 五端点 + login_requests 表 + reaper
- 三密钥分离:QR 仅含批准信息,会话只投递给持私有 poll_secret 的原设备
  (偷拍 QR 者无 poll_secret 领不到会话、未登录批不了准)
- 会话在新设备侧领取(cookie 不经手机)、单次消费、短 TTL

后端 · 薄账户层与 2FA step-up
- accounts 表(键 sub,不含任何凭证):exchange/refresh upsert match_key /
  显示名 / 头像 / roles,供显示与未来管理员开启迁移标记时跨源关联
- step-up(默认关):开启后 qr/approve 要求新鲜 prompt=login 授权码,后端就地
  换 id_token、JWKS 验签 + auth_time 窗口 + sub 匹配,provider 2FA 于此往返强制

前端(web/)
- 显码页 /link/new + 批准页 /link + net/qr.ts,对齐 Theme B、复用 AuthShell
  与聚珍排版管线、零新全局样式
- step-up 再认证流:批准前跑 prompt=login PKCE,回调分叉(不消费一次性 code、
  独立 state key)后带 step_up_code/verifier 调 approve
- 三语 i18n qr.*;新增 qrcode 依赖

修复 · clipboard sweeper(早已提交的损坏)
- ClearExpiredClipboards 因 clipboard.sql 全角注释触发 sqlc 1.31 多字节偏移
  bug,生成 SQL 被截断为「UPDATE clipboard_state SET content =」,sweeper 运行
  期报「incomplete input」、过期剪贴板内容从未清除(短 TTL 暴露保护失效)
- 注释改纯 ASCII 并加 bug 警告,重生成得完整 SQL;prod 已验证 sweeper 由 ERROR
  转为正常清理(cleared count=1)

构建
- .dockerignore:排除本地 node_modules 等,避免宿主原生二进制污染镜像内 vite 构建
- Dockerfile.base:GOPROXY 改为可经 --build-arg 覆盖(默认仍官方代理,受限网络
  构建时传区域镜像即可,仓库不固化区域值)

文档
- 新增 AUTH.md(账户与登录实施蓝本);README 特性;.env.example /
  compose.snippet 增配置项(QR / 自签会话 TTL / step-up / match_claim)

测试
- 自签 token 密钥域隔离、扫码端到端(领取 / 单次 / poll_secret 校验 / deny /
  step-up 门)、extractIdentity、web_sessions 升级迁移
2026-06-21 22:43:36 +08:00

261 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
// shortcutScope is the single scope a shortcut token is granted; the route layer
// only lets a token carrying it reach the clipboard endpoints (see server.go).
const shortcutScope = "clipboard"
// requireScope gates a route group that scoped shortcut tokens may also reach: a
// scoped token must carry the named scope, while a full login session always
// passes (it has no scope restriction).
func requireScope(scope string) func(http.Handler) http.Handler {
return func(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.Scoped() && !claims.HasScope(scope) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient scope"})
return
}
next.ServeHTTP(w, r)
})
}
}
// rejectScoped blocks scoped shortcut tokens outright — for every route only a
// full login session may touch. A leaked clipboard token thus reaches nothing
// here: not the device list, transfers, signalling, nor token self-management.
func rejectScoped(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.Scoped() {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "session token required"})
return
}
next.ServeHTTP(w, r)
})
}
// 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 自我续期或越权。
type shortcutIssueReq struct {
Label string `json:"label"`
}
type shortcutIssueResp struct {
// Token 仅在签发时返回这一次,服务端不留可还原副本(只存 jti 等元数据)。
Token string `json:"token"`
Jti string `json:"jti"`
Label string `json:"label"`
Scopes []string `json:"scopes"`
ExpiresAt int64 `json:"expires_at"`
}
type shortcutTokenView struct {
Jti string `json:"jti"`
Label string `json:"label"`
Scopes []string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
LastUsedAt *int64 `json:"last_used_at"`
Revoked bool `json:"revoked"`
}
// handleShortcutIssue mints a long-lived, clipboard-scoped HS256 token, stores
// its metadata, and returns the token string once. Disabled (503) when no HS256
// secret is configured.
func (s *Server) handleShortcutIssue(w http.ResponseWriter, r *http.Request) {
if len(s.cfg.HS256Secret) == 0 {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "shortcut tokens not enabled"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
var req shortcutIssueReq
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
}
label := strings.TrimSpace(req.Label)
if label == "" {
label = "iOS Shortcut"
}
if len(label) > 64 {
label = label[:64]
}
now := time.Now()
active, err := s.queries.CountActiveShortcutTokensByUser(r.Context(), db.CountActiveShortcutTokensByUserParams{
UserID: claims.UserID,
ExpiresAt: now.Unix(),
})
if err != nil {
slog.Error("count shortcut tokens failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if active >= int64(s.cfg.ShortcutMaxPerUser) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "too many active tokens"})
return
}
jti, err := randomTokenID()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
return
}
expires := now.Add(time.Duration(s.cfg.ShortcutTokenTTLDays) * 24 * time.Hour)
token, err := signShortcutToken(s.cfg.HS256Secret, claims.UserID, jti, shortcutScope, now, expires)
if err != nil {
slog.Error("sign shortcut token failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "sign"})
return
}
if err := s.queries.InsertShortcutToken(r.Context(), db.InsertShortcutTokenParams{
Jti: jti,
UserID: claims.UserID,
Label: label,
Scopes: shortcutScope,
CreatedAt: now.Unix(),
ExpiresAt: expires.Unix(),
}); err != nil {
slog.Error("insert shortcut token failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
writeJSON(w, http.StatusCreated, shortcutIssueResp{
Token: token,
Jti: jti,
Label: label,
Scopes: []string{shortcutScope},
ExpiresAt: expires.Unix(),
})
}
// handleShortcutList returns the caller's tokens (metadata only, never the token
// string).
func (s *Server) handleShortcutList(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
rows, err := s.queries.ListShortcutTokensByUser(r.Context(), claims.UserID)
if err != nil {
slog.Error("list shortcut tokens failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
out := make([]shortcutTokenView, 0, len(rows))
for _, t := range rows {
out = append(out, shortcutTokenView{
Jti: t.Jti,
Label: t.Label,
Scopes: strings.Fields(t.Scopes),
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
Revoked: t.Revoked != 0,
})
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
}
// handleShortcutRevoke flips the revoked flag (scoped to the caller's user_id so
// one user can't revoke another's). verifyHS256 reads the flag on every request,
// so revocation takes effect on the token's next use.
func (s *Server) handleShortcutRevoke(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
jti := chi.URLParam(r, "jti")
if jti == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing jti"})
return
}
n, err := s.queries.RevokeShortcutToken(r.Context(), db.RevokeShortcutTokenParams{
Jti: jti,
UserID: claims.UserID,
})
if err != nil {
slog.Error("revoke shortcut token failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if n == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// signShortcutToken mints a compact HS256 JWT: sub=userID, jti, scope (space-
// delimited OAuth-style, informational — the store row is authoritative), iat,
// exp. The shared HS256 secret is the same one verifyHS256 checks against.
func signShortcutToken(secret, userID, jti, scope string, iat, exp time.Time) (string, error) {
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: jwtauth.DeriveHS256Key(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
return "", err
}
std := jwt.Claims{
Subject: userID,
ID: jti,
IssuedAt: jwt.NewNumericDate(iat),
Expiry: jwt.NewNumericDate(exp),
}
return jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize()
}
// randomTokenID returns a 128-bit URL-safe random id for the jti.
func randomTokenID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}