mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-25 14:30:43 +01:00
* s3: end a catalog-vended session with the access key it was vended for A table credential the Iceberg catalog vends is a self-contained STS session: it stays valid until it expires (an hour by default) even after the access key that loaded the table is deleted or deactivated, or its identity is removed or disabled. An operator revoking a leaked key cannot cut off the table credentials that key already obtained. Sessions are stateless JWTs, so there is no revocation list to consult. This change follows the same pattern as role sessions, which are already refused once their role's ID no longer matches (checkSessionRoleBinding): a vended session carries the access key it was vended for (src_ak, recorded by the previous change), and validateSTSSessionToken checks it against the identity table held in memory. A session of provider iceberg-catalog that carries a source key is refused (InvalidAccessKeyId, as for an unknown key) once its identity is gone or disabled, or the key is no longer one of that identity's active, unexpired credentials. Scope: - Only catalog-vended sessions are checked; web-identity, AssumeRole and every other STS session are validated as before. - The vendor binds a session to the caller's key only when the caller is a stored identity holding that key. A caller that signed with another STS session's key, an OIDC user, or a request that recorded no key gets an unbound session, as today; binding it would refuse every request made with it. A session minted before this change carries no key and is unbound. - The check is one map lookup under the read lock and a scan of one identity's keys (about 11 ns, 0 allocations, with 1,000 identities). Tests (s3api_iceberg_vended_session_test.go) mint real sessions through the catalog vendor and validate them the way a data-path request is validated: deactivating, expiring or deleting key A ends A's session while the session vended through key B on the same identity keeps working; disabling or deleting the identity ends it; callers that are not a stored identity holding the key get unbound sessions that keep working; other STS sessions are not checked. The first two tests and the third fail without this change. * s3: keep the vended-session key binding when the key dies before vend vendedSessionKeyLive gated the binding, so a key revoked between the request's authentication and the vend skipped it and minted an unbound session that stayed usable until it expired. Every catalog auth path pairs the identity name in context with the key that authenticated, so a stored-identity principal always means the request signed with that identity's own key. Bind whenever the principal is a stored identity: a session bound to a now-dead key is refused on its first request, which is what the binding is for. * s3: gate the vended-session key binding on the authenticated caller type lookupByIdentityName(principal) named only that some identity exists, not that the request signed with a stored key: an STS session or OIDC principal that collides with an identity name got a session bound to its temporary key, which vendedSessionKeyLive then refused on every request. Verifiers now record the verified key as a stored credential, and the vendor binds only when that marker is present — keeping the binding even when the key was revoked between authentication and the vend. * s3: enforce the vended key binding under a dedicated claim Sessions minted before the binding existed carry the caller's key in the audit-only SourceAccessKeyId — including temporary STS keys that can never resolve in the identity table. Enforcing on that field would end live table credentials at upgrade; the binding now travels under BoundAccessKeyId, leaving legacy sessions unbound as before. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: bind vended sessions to a credential fingerprint The key id alone cannot identify a credential: an operator can delete a key and re-create it under the same id with a different secret, reviving sessions minted for the original. Verifiers now record a fingerprint of the secret at authentication; the session dies unless it still matches. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: keep recording the caller's key on every catalog session SourceAccessKeyId was unconditional audit metadata before the binding existed; gating it on the stored-credential marker dropped the record for callers that authenticated another way. With the binding under its own claim there is no ambiguity left, so the field records the caller's key again regardless of how it authenticated. * s3: key the vended-session credential fingerprint with the signing key The bound-credential fingerprint travels inside the vended session token. As a plain SHA-256 of the secret it gave any token holder an offline guessing oracle for weak secrets, and CodeQL flags it twice. Store the verified secret in the request marker instead and HMAC it with the STS signing key at vend and at validation. The claim can no longer be mined, and the per-request auth path stops hashing secrets. --------- Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com> Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
255 lines
8.4 KiB
Go
255 lines
8.4 KiB
Go
package lance
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
jwt "github.com/golang-jwt/jwt/v5"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
)
|
|
|
|
// OAuthTokenResponse is the response for POST /oauth/token.
|
|
type OAuthTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Scope string `json:"scope,omitempty"`
|
|
}
|
|
|
|
// OAuthErrorResponse is the error response for the OAuth endpoint.
|
|
type OAuthErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Description string `json:"error_description,omitempty"`
|
|
}
|
|
|
|
// LanceClaims are JWT claims for Lance catalog OAuth tokens.
|
|
type LanceClaims struct {
|
|
IdentityName string `json:"identity_name"`
|
|
AccessKey string `json:"access_key"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
const defaultOauthTokenExpiry = 3600
|
|
|
|
// maxOauthTokenExpiry bounds the configured TTL so seconds-to-Duration
|
|
// conversions cannot overflow into already-expired tokens.
|
|
const maxOauthTokenExpiry = 365 * 24 * 3600
|
|
|
|
const grantTypeClientCredentials = "client_credentials"
|
|
|
|
// oauthExpirySeconds returns the OAuth token TTL. Lance clients hold a static
|
|
// Authorization header and cannot refresh on 401, so deployments can raise
|
|
// this to survive beyond the default hour.
|
|
func oauthExpirySeconds() int {
|
|
if v := os.Getenv("LANCE_OAUTH_TOKEN_EXPIRY"); v != "" {
|
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
|
|
if n > maxOauthTokenExpiry {
|
|
return maxOauthTokenExpiry
|
|
}
|
|
return int(n)
|
|
}
|
|
}
|
|
return defaultOauthTokenExpiry
|
|
}
|
|
|
|
// handleOAuthTokens implements the OAuth2 client_credentials flow.
|
|
// POST /oauth/token
|
|
func (s *Server) handleOAuthTokens(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
|
if err := r.ParseForm(); err != nil {
|
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Could not parse form body")
|
|
return
|
|
}
|
|
|
|
// Reject credentials in query string to prevent leaking secrets into logs and caches.
|
|
if r.URL.Query().Get("client_secret") != "" {
|
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_secret must not be sent in the URL")
|
|
return
|
|
}
|
|
|
|
if grantType := r.PostFormValue("grant_type"); grantType != grantTypeClientCredentials {
|
|
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
|
|
fmt.Sprintf("Unsupported grant_type: %s", grantType))
|
|
return
|
|
}
|
|
|
|
clientID := r.PostFormValue("client_id")
|
|
clientSecret := r.PostFormValue("client_secret")
|
|
|
|
// Also support HTTP Basic auth per OAuth2 spec
|
|
if clientID == "" && clientSecret == "" {
|
|
var ok bool
|
|
clientID, clientSecret, ok = r.BasicAuth()
|
|
if !ok {
|
|
writeInvalidClient(w, "Missing client credentials")
|
|
return
|
|
}
|
|
}
|
|
|
|
if clientID == "" || clientSecret == "" {
|
|
writeInvalidClient(w, "Missing client_id or client_secret")
|
|
return
|
|
}
|
|
|
|
if s.credentialValidator == nil {
|
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Credential validation not configured")
|
|
return
|
|
}
|
|
|
|
identityName, _, err := s.credentialValidator.ValidateS3Credential(clientID, clientSecret)
|
|
if err != nil {
|
|
glog.V(2).Infof("Lance OAuth: credential validation failed for client_id=%s: %v", clientID, err)
|
|
writeInvalidClient(w, "Invalid client credentials")
|
|
return
|
|
}
|
|
|
|
tokenString, err := mintToken(identityName, clientID, clientSecret, oauthExpirySeconds())
|
|
if err != nil {
|
|
glog.Errorf("Lance OAuth: failed to sign token: %v", err)
|
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Failed to generate token")
|
|
return
|
|
}
|
|
|
|
resp := OAuthTokenResponse{
|
|
AccessToken: tokenString,
|
|
TokenType: "bearer",
|
|
ExpiresIn: oauthExpirySeconds(),
|
|
Scope: r.PostFormValue("scope"),
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// mintToken issues a signed access token for the given identity and
|
|
// credential, valid for ttlSeconds.
|
|
func mintToken(identityName, accessKey, secret string, ttlSeconds int) (string, error) {
|
|
signingKey := deriveSigningKey(accessKey, secret)
|
|
now := time.Now()
|
|
claims := LanceClaims{
|
|
IdentityName: identityName,
|
|
AccessKey: accessKey,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlSeconds) * time.Second)),
|
|
Issuer: "seaweedfs-lance",
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(signingKey)
|
|
}
|
|
|
|
// authenticateBearer validates a Bearer token from the Authorization header.
|
|
// Returns the identity name, identity object, and whether auth succeeded.
|
|
func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) {
|
|
identityName, identity, _, _, ok := s.authenticateBearerKey(r)
|
|
return identityName, identity, ok
|
|
}
|
|
|
|
// authenticateBearerKey is authenticateBearer that also returns the access key
|
|
// the token was minted from (its verified access_key claim).
|
|
func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, string, string, bool) {
|
|
auth := r.Header.Get("Authorization")
|
|
if auth == "" {
|
|
return "", nil, "", "", false
|
|
}
|
|
if !strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
|
return "", nil, "", "", false
|
|
}
|
|
tokenString := strings.TrimSpace(auth[7:])
|
|
if tokenString == "" {
|
|
return "", nil, "", "", false
|
|
}
|
|
|
|
if s.credentialValidator == nil {
|
|
return "", nil, "", "", false
|
|
}
|
|
|
|
// Parse the token without verification first to get the access key,
|
|
// then look up the exact credential to verify the signature.
|
|
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
unverified := &LanceClaims{}
|
|
_, _, err := parser.ParseUnverified(tokenString, unverified)
|
|
if err != nil {
|
|
glog.V(2).Infof("Lance OAuth: failed to parse token: %v", err)
|
|
return "", nil, "", "", false
|
|
}
|
|
|
|
if unverified.AccessKey == "" {
|
|
return "", nil, "", "", false
|
|
}
|
|
|
|
identityName, identity, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey)
|
|
if err != nil {
|
|
glog.V(2).Infof("Lance OAuth: failed to get credential for access key: %v", err)
|
|
return "", nil, "", "", false
|
|
}
|
|
|
|
signingKey := deriveSigningKey(unverified.AccessKey, secretKey)
|
|
claims := &LanceClaims{}
|
|
verified, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
return signingKey, nil
|
|
})
|
|
if err != nil || !verified.Valid {
|
|
glog.V(2).Infof("Lance OAuth: token verification failed: %v", err)
|
|
return "", nil, "", "", false
|
|
}
|
|
|
|
// The token's signing key is derived from this access key, so a verified
|
|
// token was minted from it.
|
|
return identityName, identity, unverified.AccessKey, secretKey, true
|
|
}
|
|
|
|
// authenticateApiKey validates an x-api-key header carrying an S3 credential
|
|
// as "access_key:secret_key". Unlike a Bearer token it does not expire, which
|
|
// suits clients that hold static headers.
|
|
func (s *Server) authenticateApiKey(apiKey string) (string, interface{}, bool) {
|
|
if s.credentialValidator == nil {
|
|
return "", nil, false
|
|
}
|
|
accessKey, secretKey, ok := strings.Cut(apiKey, ":")
|
|
if !ok || accessKey == "" || secretKey == "" {
|
|
return "", nil, false
|
|
}
|
|
identityName, identity, err := s.credentialValidator.ValidateS3Credential(accessKey, secretKey)
|
|
if err != nil {
|
|
glog.V(2).Infof("Lance x-api-key: credential validation failed: %v", err)
|
|
return "", nil, false
|
|
}
|
|
return identityName, identity, true
|
|
}
|
|
|
|
// deriveSigningKey derives a signing key from the access key and secret using HMAC-SHA256.
|
|
// Including the access key prevents cross-credential token forgery when two
|
|
// credentials happen to share the same secret.
|
|
func deriveSigningKey(accessKey, secret string) []byte {
|
|
h := hmac.New(sha256.New, []byte("seaweedfs-lance-oauth"))
|
|
h.Write([]byte(accessKey))
|
|
h.Write([]byte{0}) // null separator
|
|
h.Write([]byte(secret))
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
// writeInvalidClient answers 401 with the Basic challenge RFC 6749 §5.2
|
|
// requires, so a client knows which scheme to retry with.
|
|
func writeInvalidClient(w http.ResponseWriter, description string) {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="lance"`)
|
|
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", description)
|
|
}
|
|
|
|
func writeOAuthError(w http.ResponseWriter, status int, errCode, description string) {
|
|
resp := OAuthErrorResponse{
|
|
Error: errCode,
|
|
Description: description,
|
|
}
|
|
writeJSON(w, status, resp)
|
|
}
|