Files
Commilitia-Drop/internal/jwtauth/middleware.go
T
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
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)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

320 lines
10 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 jwtauth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"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"
)
// Store is the subset of *db.Queries the auth middleware uses: device upserts
// plus the shortcut-token lookups needed to honour revocation. Declared as an
// interface so tests can swap in a fake.
type Store interface {
UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error
GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error)
TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error
}
type Authenticator struct {
cfg *config.Config
store Store
jwks *jwksCache
hsKey []byte
}
func New(cfg *config.Config, store Store) *Authenticator {
a := &Authenticator{
cfg: cfg,
store: store,
}
if cfg.HS256Secret != "" {
a.hsKey = DeriveHS256Key(cfg.HS256Secret)
}
if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" {
a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute)
}
return a
}
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
unauthorized(w, "missing bearer token")
return
}
claims, err := a.verify(r.Context(), token, r)
if err != nil {
slog.Warn("auth failed", "err", err, "path", r.URL.Path)
unauthorized(w, "invalid token")
return
}
deviceName := sanitizeDeviceName(r.Header.Get("X-Device-Name"))
deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type"))
if claims.Scoped() {
// The backend authoritatively knows a scoped token is the iOS
// Shortcut, so it labels the device "shortcut" regardless of any
// header. ("ios" stays reserved for a real native client.)
deviceType = "shortcut"
}
// Register a device only when the client names itself (X-Device-Name).
// A nameless request — e.g. a polling shortcut hitting /api/clipboard/version
// without the header — must NOT be registered: the old random UA fallback
// minted a fresh "Unknown Device" row on every request and flooded the list.
if deviceName != "" {
if err := a.store.UpsertDevice(r.Context(), db.UpsertDeviceParams{
UserID: claims.UserID,
Name: deviceName,
Type: deviceType,
LastSeen: time.Now().Unix(),
}); err != nil {
// non-fatal: log and continue so transient DB errors don't 401 users
slog.Error("device upsert failed",
"err", err, "user", claims.UserID, "device", deviceName)
}
}
ctx := context.WithValue(r.Context(), claimsCtxKey, claims)
ctx = context.WithValue(ctx, deviceCtxKey, deviceName)
ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (a *Authenticator) verify(ctx context.Context, token string, r *http.Request) (*Claims, error) {
if a.cfg.AuthMode == "dev" {
return a.verifyDev(token, r)
}
if c, err := a.verifyHS256(ctx, token); err == nil {
return c, nil
}
return a.verifyRS256(ctx, token)
}
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")
}
userID := r.Header.Get("X-Dev-User")
if userID == "" {
userID = "dev-user"
}
return &Claims{UserID: userID, Groups: []string{"dev"}}, nil
}
func (a *Authenticator) verifyHS256(ctx context.Context, token string) (*Claims, error) {
if len(a.hsKey) == 0 {
return nil, errors.New("HS256 secret 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.hsKey, &std, &custom); err != nil {
return nil, err
}
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
return nil, err
}
claims, err := claimsFromJWT(std, custom)
if err != nil {
return nil, err
}
// HS256 is only ever used to mint scoped shortcut tokens, and those ALWAYS
// carry a jti. A validly-signed HS256 token without one must be rejected
// outright: otherwise it would fall through here as a full, unscoped account
// session with a self-declared subject — far beyond the clipboard-only
// surface HS256 is meant for. Requiring the jti keeps a leaked HS256 secret's
// blast radius pinned to the shortcut scope (clipboard, and nothing else).
if std.ID == "" {
return nil, errors.New("HS256 token missing jti")
}
// The signature alone is not enough — the token must still be present and not
// revoked in the store, so a leaked or retired token can be killed
// server-side. The stored row is also the authoritative source of scopes
// (never trust scopes off the wire).
row, err := a.store.GetShortcutToken(ctx, std.ID)
if err != nil {
return nil, fmt.Errorf("shortcut token lookup: %w", err)
}
if row.Revoked != 0 {
return nil, errors.New("shortcut token revoked")
}
if row.UserID != claims.UserID {
return nil, errors.New("shortcut token subject mismatch")
}
claims.JTI = std.ID
claims.Scopes = strings.Fields(row.Scopes)
a.touchTokenAsync(std.ID)
return claims, nil
}
// touchTokenAsync records a shortcut token's last-use time off the request path
// — it's audit metadata, so a slow or failing write must never delay or fail
// the request it belongs to.
func (a *Authenticator) touchTokenAsync(jti string) {
now := time.Now().Unix()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := a.store.TouchShortcutTokenUsed(ctx, db.TouchShortcutTokenUsedParams{
LastUsedAt: &now,
Jti: jti,
}); err != nil {
slog.Warn("touch shortcut token failed", "err", err, "jti", jti)
}
}()
}
func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims, error) {
if a.jwks == nil {
return nil, errors.New("JWKS not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
if err != nil {
return nil, err
}
if len(parsed.Headers) == 0 {
return nil, errors.New("missing token headers")
}
kid := parsed.Headers[0].KeyID
key, err := a.jwks.GetKey(ctx, kid)
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(key, &std, &custom); err != nil {
return nil, 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 nil, err
}
return claimsFromJWT(std, custom)
}
// 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
// AnyAudience passes when the token's audience matches any one entry. Whitespace
// around entries is trimmed and empties dropped, so a plain single value behaves
// exactly as before.
func parseAudiences(raw string) jwt.Audience {
parts := strings.Split(raw, ",")
out := make(jwt.Audience, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}
func claimsFromJWT(std jwt.Claims, custom map[string]any) (*Claims, error) {
if std.Subject == "" {
return nil, errors.New("missing subject claim")
}
c := &Claims{UserID: std.Subject}
if g, ok := custom["groups"].([]any); ok {
for _, item := range g {
if s, ok := item.(string); ok {
c.Groups = append(c.Groups, s)
}
}
}
return c, nil
}
func bearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(h, prefix) {
return "", false
}
tok := strings.TrimPrefix(h, prefix)
if tok == "" {
return "", false
}
return tok, true
}
func unauthorized(w http.ResponseWriter, reason string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("WWW-Authenticate", `Bearer realm="cdrop"`)
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "unauthorized",
"reason": reason,
})
}
// DeriveHS256Key turns a configured secret of any length into a fixed 32-byte
// HMAC key. HS256 requires >= 32 bytes; hashing guarantees that (and keeps the
// minting and verifying sides in lockstep) so a short CDROP_HS256_SECRET can't
// make shortcut-token signing fail. Both signing (httpapi) and verifying use it.
func DeriveHS256Key(secret string) []byte {
sum := sha256.Sum256([]byte(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
// is restricted to printable ASCII everywhere. Here we keep only printable ASCII
// (0x200x7E), trim, and cap the length as a server-side backstop; clients also
// validate the name up front for a clear error. Empty after sanitising → the
// caller falls back to a UA-derived default.
func sanitizeDeviceName(raw string) string {
var b strings.Builder
for _, r := range raw {
if r >= 0x20 && r <= 0x7E {
b.WriteRune(r)
}
}
name := strings.TrimSpace(b.String())
if len(name) > 64 {
name = strings.TrimSpace(name[:64])
}
return name
}
// normalizeDeviceType whitelists the client-declared X-Device-Type so a device
// row only ever carries a known kind; anything unrecognised (incl. empty) falls
// back to "browser", the default web client. Desktop clients send macos/windows;
// "ios" is reserved for a future native iOS client (the Shortcut never sends it).
func normalizeDeviceType(raw string) string {
switch t := strings.ToLower(strings.TrimSpace(raw)); t {
case "macos", "windows", "linux", "ios", "browser":
return t
default:
return "browser"
}
}