f21fa5b5e8
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
241 lines
7.9 KiB
Go
241 lines
7.9 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)
|
||
})
|
||
}
|
||
|
||
// 这些端点只由登录会话调用(路由层 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
|
||
}
|