Files
seaweedfs/weed/security/jwt.go
T
Chris LuandDevin 4d1f49c638 filer.sync: sign proxied chunk I/O from the per-side security file (#11645)
* filer.sync: sign proxied chunk I/O from the per-side security file

The -a.security / -b.security files were used for gRPC TLS and the
HTTPS client but not for jwt.filer_signing, so filer-proxied chunk
reads and writes carried a token signed with the process-wide key and
failed authorization whenever the two clusters' keys differ.

LoadFilerJwtFromFile returns a FilerJwtProvider for each side's file,
which FilerSource and FilerSink now accept for proxied chunk reads and
writes. With no keys in the file or no flag, both fall back to the
process-wide jwt.filer_signing configuration as before.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* replication: use the side filer read key for manifest downloads and fall back per access level

Manifest chunk resolution still signed proxied downloads with the
process-wide read key, so a source filer requiring its own key 401'd on
manifest-bearing files. A side security file that set only one access
level also produced empty tokens for the other instead of inheriting the
process-wide key, and the side file loader ignored the WEED_ environment
overrides the filer itself honors.

ResolveChunkManifest/ResolveOneChunkManifest keep their signatures;
FilerJwt-aware variants thread the provider down to fetchWholeChunk,
which prefers it on proxy URLs. The side loader now applies the same
environment precedence and falls back to the process-wide signer per
missing access level.

* security: verify the configured filer token lifetimes

* security: reject negative filer token lifetimes

A negative expires_after_seconds reached GenJwtForFilerServer and produced
a token with no expiration claim. Also synchronize the Authorization-header
capture in the proxy test and restore the prior viper key on cleanup.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:04:06 +08:00

210 lines
7.0 KiB
Go

package security
import (
"fmt"
"net/http"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/spf13/viper"
)
type EncodedJwt string
type SigningKey []byte
// BearerPrefix is the RFC 6750 Authorization header scheme prefix for bearer
// tokens. Used when constructing "Authorization: Bearer <token>" headers; the
// scheme name itself is matched case-insensitively when parsing (see GetJwt).
const BearerPrefix = "Bearer "
// SeaweedFileIdClaims is created by Master server(s) and consumed by Volume server(s),
// restricting the access this JWT allows to only a single file.
type SeaweedFileIdClaims struct {
Fid string `json:"fid"`
jwt.RegisteredClaims
}
// SeaweedFilerClaims is created e.g. by S3 proxy server and consumed by Filer server.
// Right now, it only contains the standard claims; but this might be extended later
// for more fine-grained permissions.
type SeaweedFilerClaims struct {
AllowedPrefixes []string `json:"allowed_prefixes,omitempty"`
AllowedMethods []string `json:"allowed_methods,omitempty"`
// SessionId is present only on STS session tokens; a token carrying it is
// an S3 session credential, not a filer API credential.
SessionId string `json:"sid,omitempty"`
jwt.RegisteredClaims
}
// SeaweedFilerAdminClaims is presented by callers of the filer's IAM gRPC
// service to prove they are authorised to administer users, access keys, and
// policies. The token is signed with the filer write-signing key
// (jwt.filer_signing.key in security.toml).
//
// Validation is delegated to DecodeJwt below: it enforces HS256 via the
// SigningMethodHMAC type check, and jwt/v5 validates exp/nbf on the embedded
// RegisteredClaims. Extra JSON fields in the payload are silently ignored by
// encoding/json, which is the desired behaviour here (forward-compat).
type SeaweedFilerAdminClaims struct {
// SessionId is present only on STS session tokens; a token carrying it is
// an S3 session credential, not a filer admin credential.
SessionId string `json:"sid,omitempty"`
jwt.RegisteredClaims
}
// GenJwtForFilerAdmin mints a Bearer token for the filer IAM gRPC service.
// Returns an empty string if the signing key is not configured.
func GenJwtForFilerAdmin(signingKey SigningKey, expiresAfterSec int) EncodedJwt {
if len(signingKey) == 0 {
return ""
}
claims := SeaweedFilerAdminClaims{
RegisteredClaims: jwt.RegisteredClaims{},
}
if expiresAfterSec > 0 {
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(expiresAfterSec)))
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
encoded, e := t.SignedString([]byte(signingKey))
if e != nil {
glog.V(0).Infof("Failed to sign claims %+v: %v", t.Claims, e)
return ""
}
return EncodedJwt(encoded)
}
func GenJwtForVolumeServer(signingKey SigningKey, expiresAfterSec int, fileId string) EncodedJwt {
if len(signingKey) == 0 {
return ""
}
claims := SeaweedFileIdClaims{
fileId,
jwt.RegisteredClaims{},
}
if expiresAfterSec > 0 {
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(expiresAfterSec)))
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
encoded, e := t.SignedString([]byte(signingKey))
if e != nil {
glog.V(0).Infof("Failed to sign claims %+v: %v", t.Claims, e)
return ""
}
return EncodedJwt(encoded)
}
// GenJwtForFilerServer creates a JSON-web-token for using the authenticated Filer API. Used f.e. inside
// the S3 API
func GenJwtForFilerServer(signingKey SigningKey, expiresAfterSec int) EncodedJwt {
if len(signingKey) == 0 {
return ""
}
claims := SeaweedFilerClaims{
RegisteredClaims: jwt.RegisteredClaims{},
}
if expiresAfterSec > 0 {
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(expiresAfterSec)))
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
encoded, e := t.SignedString([]byte(signingKey))
if e != nil {
glog.V(0).Infof("Failed to sign claims %+v: %v", t.Claims, e)
return ""
}
return EncodedJwt(encoded)
}
func GetJwt(r *http.Request) EncodedJwt {
// Get token from query params
tokenStr := r.URL.Query().Get("jwt")
// Get token from authorization header
if tokenStr == "" {
bearer := r.Header.Get("Authorization")
if len(bearer) > 7 && strings.ToUpper(bearer[0:6]) == "BEARER" {
tokenStr = bearer[7:]
}
}
// Get token from http only cookie
if tokenStr == "" {
token, err := r.Cookie("AT")
if err == nil {
tokenStr = token.Value
}
}
return EncodedJwt(tokenStr)
}
func DecodeJwt(signingKey SigningKey, tokenString EncodedJwt, claims jwt.Claims) (token *jwt.Token, err error) {
// check exp, nbf
return jwt.ParseWithClaims(string(tokenString), claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unknown token method")
}
return []byte(signingKey), nil
})
}
// FilerJwtProvider signs the credential a filer's HTTP API expects. A nil
// provider means the process-wide jwt.filer_signing configuration applies.
type FilerJwtProvider func(isWrite bool) EncodedJwt
// LoadFilerJwtFromFile reads jwt.filer_signing from a security file the way
// LoadClientTLSFromFile reads the TLS section, honoring the same WEED_
// environment precedence. A nil provider means the file configures no filer
// signing keys and the process-wide configuration applies. A file that sets
// only one access level falls back to the process-wide key for the other.
func LoadFilerJwtFromFile(configFile string) (FilerJwtProvider, error) {
v := viper.New()
v.SetConfigFile(configFile)
v.AutomaticEnv()
v.SetEnvPrefix("weed")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read security config %s: %v", configFile, err)
}
signingKey := SigningKey(v.GetString("jwt.filer_signing.key"))
readSigningKey := SigningKey(v.GetString("jwt.filer_signing.read.key"))
if len(signingKey) == 0 && len(readSigningKey) == 0 {
return nil, nil
}
signingKeyExpires := v.GetInt("jwt.filer_signing.expires_after_seconds")
readSigningKeyExpires := v.GetInt("jwt.filer_signing.read.expires_after_seconds")
if len(signingKey) == 0 || len(readSigningKey) == 0 {
gv := util.GetViper()
if len(signingKey) == 0 {
signingKey = SigningKey(gv.GetString("jwt.filer_signing.key"))
signingKeyExpires = gv.GetInt("jwt.filer_signing.expires_after_seconds")
}
if len(readSigningKey) == 0 {
readSigningKey = SigningKey(gv.GetString("jwt.filer_signing.read.key"))
readSigningKeyExpires = gv.GetInt("jwt.filer_signing.read.expires_after_seconds")
}
}
if signingKeyExpires < 0 || readSigningKeyExpires < 0 {
return nil, fmt.Errorf("jwt.filer_signing lifetimes must not be negative")
}
if signingKeyExpires == 0 {
signingKeyExpires = 10
}
if readSigningKeyExpires == 0 {
readSigningKeyExpires = 60
}
return func(isWrite bool) EncodedJwt {
if isWrite {
return GenJwtForFilerServer(signingKey, signingKeyExpires)
}
return GenJwtForFilerServer(readSigningKey, readSigningKeyExpires)
}, nil
}