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 升级迁移
261 lines
8.7 KiB
Go
261 lines
8.7 KiB
Go
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
|
||
}
|