mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-26 09:54:47 +00:00
lance: authenticate the catalog with Bearer tokens and x-api-key (#11431)
* lance: accept OAuth2 bearer tokens for catalog auth Lance and LanceDB clients can only send OAuth2 / Bearer / API-Key headers on catalog calls, never SigV4, so behind an auth-enabled S3 gateway every namespace request failed with 403 Access Denied. Mirror the Iceberg catalog's OAuth2 support: POST /oauth/token accepts an S3 access key / secret key as client_id / client_secret, validates them against IAM, and returns a signed JWT. The Auth middleware accepts that token as a Bearer credential before falling through to SigV4. Closes #11430 * lance: accept x-api-key header carrying an S3 credential The Lance namespace spec's third auth scheme maps api_key onto the x-api-key header. Accept "access_key:secret_key" there and validate it against IAM, so clients that only hold static headers can authenticate without minting a token first. * lance: answer invalid_client with the Basic challenge RFC 6749 5.2 requires a 401 from the token endpoint to carry WWW-Authenticate matching the scheme the client used, so it knows how to retry. * lance: cap the token endpoint request body /oauth/token is unauthenticated, so ParseForm needs the same size bound decodeBody applies to every other catalog request. * lance: keep query strings out of request logs /oauth/token rejects a client_secret sent in the query, but the logging middleware and the catch-all wrote RequestURI to the log before that rejection ran. Log the path alone so a mis-sent secret never reaches the log. * lance: log the escaped path, not the decoded one URL.Path decodes percent escapes, so a request like /%0aFORGED could split log lines. EscapedPath keeps the encoding while still dropping the query string.
This commit is contained in:
@@ -618,6 +618,7 @@ func (s3opt *S3Options) startLanceServer(s3ApiServer *s3api.S3ApiServer) {
|
||||
lanceRouter.Use(util_http.EscapeSemicolonsInQuery)
|
||||
|
||||
lanceServer := lance.NewServer(s3ApiServer, s3ApiServer)
|
||||
lanceServer.SetCredentialValidator(s3ApiServer)
|
||||
if s3opt.icebergCredentialRole != nil && *s3opt.icebergCredentialRole != "" {
|
||||
lanceServer.SetCredentialVendor(lanceCredentialVendor{s3ApiServer})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
return identityName, identity, 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)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package lance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
type mockCredentialValidator struct {
|
||||
credentials map[string]string // accessKey -> secretKey
|
||||
identities map[string]string // accessKey -> identityName
|
||||
}
|
||||
|
||||
func (m *mockCredentialValidator) ValidateS3Credential(accessKey, secretKey string) (string, interface{}, error) {
|
||||
expected, ok := m.credentials[accessKey]
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("access key not found")
|
||||
}
|
||||
if expected != secretKey {
|
||||
return "", nil, fmt.Errorf("invalid secret key")
|
||||
}
|
||||
return m.identities[accessKey], nil, nil
|
||||
}
|
||||
|
||||
func (m *mockCredentialValidator) GetCredentialByAccessKey(accessKey string) (string, interface{}, string, error) {
|
||||
secret, ok := m.credentials[accessKey]
|
||||
if !ok {
|
||||
return "", nil, "", fmt.Errorf("access key not found")
|
||||
}
|
||||
return m.identities[accessKey], nil, secret, nil
|
||||
}
|
||||
|
||||
func newTestServerWithOAuth() *Server {
|
||||
return &Server{
|
||||
credentialValidator: &mockCredentialValidator{
|
||||
credentials: map[string]string{"AKID123": "secret456"},
|
||||
identities: map[string]string{"AKID123": "testuser"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type mockS3Authenticator struct {
|
||||
called bool
|
||||
errCode s3err.ErrorCode
|
||||
}
|
||||
|
||||
func (m *mockS3Authenticator) AuthenticateRequest(r *http.Request) (string, interface{}, s3err.ErrorCode) {
|
||||
m.called = true
|
||||
if m.errCode != s3err.ErrNone {
|
||||
return "", nil, m.errCode
|
||||
}
|
||||
return "s3user", nil, s3err.ErrNone
|
||||
}
|
||||
|
||||
func (m *mockS3Authenticator) DefaultAllow() bool { return false }
|
||||
|
||||
func mintTestToken(t *testing.T, accessKey, secret string, issuedAt, expiresAt time.Time) string {
|
||||
t.Helper()
|
||||
key := deriveSigningKey(accessKey, secret)
|
||||
claims := LanceClaims{
|
||||
IdentityName: "testuser",
|
||||
AccessKey: accessKey,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(issuedAt),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
Issuer: "seaweedfs-lance",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
func TestHandleOAuthTokens_Success(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
|
||||
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.handleOAuthTokens(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp OAuthTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.TokenType != "bearer" {
|
||||
t.Errorf("expected token_type=bearer, got %s", resp.TokenType)
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access_token")
|
||||
}
|
||||
if resp.ExpiresIn != oauthExpirySeconds() {
|
||||
t.Errorf("expected expires_in=%d, got %d", oauthExpirySeconds(), resp.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOAuthTokens_InvalidCredentials(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
body := "grant_type=client_credentials&client_id=AKID123&client_secret=wrongsecret"
|
||||
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.handleOAuthTokens(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if wa := w.Header().Get("WWW-Authenticate"); wa != `Basic realm="lance"` {
|
||||
t.Fatalf("WWW-Authenticate = %q, want Basic challenge", wa)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOAuthTokens_OversizedBody(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
body := "grant_type=client_credentials&client_id=" + strings.Repeat("x", maxRequestBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.handleOAuthTokens(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOAuthTokens_UnsupportedGrantType(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
body := "grant_type=authorization_code&client_id=AKID123&client_secret=secret456"
|
||||
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.handleOAuthTokens(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenRoundTrip(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
|
||||
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleOAuthTokens(w, req)
|
||||
|
||||
var resp OAuthTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authReq := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
|
||||
authReq.Header.Set("Authorization", "Bearer "+resp.AccessToken)
|
||||
|
||||
identityName, _, ok := s.authenticateBearer(authReq)
|
||||
if !ok {
|
||||
t.Fatal("expected Bearer auth to succeed")
|
||||
}
|
||||
if identityName != "testuser" {
|
||||
t.Errorf("expected identity 'testuser', got '%s'", identityName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenInvalid(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
|
||||
_, _, ok := s.authenticateBearer(req)
|
||||
if ok {
|
||||
t.Error("expected Bearer auth to fail with invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
// The issue-11430 reproduction: behind an auth-enabled gateway, a catalog
|
||||
// request with no signature is denied, but the same request carrying a Bearer
|
||||
// token minted from S3 credentials must pass.
|
||||
func TestAuthBearerRunsHandler(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
auth := &mockS3Authenticator{errCode: s3err.ErrAccessDenied}
|
||||
s.authenticator = auth
|
||||
now := time.Now()
|
||||
fresh := mintTestToken(t, "AKID123", "secret456", now, now.Add(time.Hour))
|
||||
|
||||
var gotIdentity string
|
||||
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotIdentity = s3_constants.GetIdentityNameFromContext(r)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+fresh)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("fresh Bearer: status = %d, want 200", rec.Code)
|
||||
}
|
||||
if auth.called {
|
||||
t.Fatalf("fresh Bearer should not need the S3 authenticator")
|
||||
}
|
||||
if gotIdentity != "testuser" {
|
||||
t.Fatalf("identity in context = %q, want testuser", gotIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
// An expired or malformed Bearer token answers 401 rather than falling
|
||||
// through to the S3 authenticator, which would misread the header as SigV4.
|
||||
func TestAuthExpiredBearerReturns401(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
auth := &mockS3Authenticator{errCode: s3err.ErrAccessDenied}
|
||||
s.authenticator = auth
|
||||
now := time.Now()
|
||||
expired := mintTestToken(t, "AKID123", "secret456", now.Add(-2*time.Hour), now.Add(-1*time.Hour))
|
||||
|
||||
var handlerCalled bool
|
||||
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
for _, scheme := range []string{"Bearer", "bearer", "BEARER"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
|
||||
req.Header.Set("Authorization", scheme+" "+expired)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("scheme %q: status = %d, want 401", scheme, rec.Code)
|
||||
}
|
||||
if wa := rec.Header().Get("WWW-Authenticate"); wa != "Bearer" {
|
||||
t.Fatalf("scheme %q: WWW-Authenticate = %q, want Bearer", scheme, wa)
|
||||
}
|
||||
}
|
||||
if auth.called {
|
||||
t.Fatalf("expired Bearer must not fall through to the S3 authenticator")
|
||||
}
|
||||
if handlerCalled {
|
||||
t.Fatalf("handler must not run for an expired token")
|
||||
}
|
||||
}
|
||||
|
||||
// x-api-key carries "access_key:secret_key" straight to the catalog, with no
|
||||
// token to mint or expire - the header form LanceDB documents for API keys.
|
||||
func TestAuthApiKeyRunsHandler(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
auth := &mockS3Authenticator{errCode: s3err.ErrAccessDenied}
|
||||
s.authenticator = auth
|
||||
|
||||
var gotIdentity string
|
||||
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotIdentity = s3_constants.GetIdentityNameFromContext(r)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
|
||||
req.Header.Set("x-api-key", "AKID123:secret456")
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("x-api-key: status = %d, want 200", rec.Code)
|
||||
}
|
||||
if gotIdentity != "testuser" {
|
||||
t.Fatalf("identity in context = %q, want testuser", gotIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthApiKeyInvalid(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
auth := &mockS3Authenticator{errCode: s3err.ErrAccessDenied}
|
||||
s.authenticator = auth
|
||||
|
||||
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
for _, key := range []string{"AKID123:wrongsecret", "AKID123", ":secret456", "unknown:key"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
|
||||
req.Header.Set("x-api-key", key)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("x-api-key %q: status = %d, want 401", key, rec.Code)
|
||||
}
|
||||
}
|
||||
if auth.called {
|
||||
t.Fatalf("bad x-api-key must not fall through to the S3 authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthNoBearerStillUsesS3Authenticator(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
auth := &mockS3Authenticator{errCode: s3err.ErrNone}
|
||||
s.authenticator = auth
|
||||
|
||||
var handlerCalled bool
|
||||
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
if !auth.called {
|
||||
t.Fatalf("request without Bearer header must use the S3 authenticator")
|
||||
}
|
||||
if rec.Code != http.StatusOK || !handlerCalled {
|
||||
t.Fatalf("status = %d, handler called = %v", rec.Code, handlerCalled)
|
||||
}
|
||||
}
|
||||
+68
-10
@@ -3,6 +3,7 @@ package lance
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -23,6 +24,18 @@ type S3Authenticator interface {
|
||||
DefaultAllow() bool
|
||||
}
|
||||
|
||||
// CredentialValidator validates S3 access key / secret key pairs
|
||||
// and provides credential lookup for OAuth token verification.
|
||||
type CredentialValidator interface {
|
||||
// ValidateS3Credential checks if the access key and secret key are valid.
|
||||
// Returns the identity name and identity object on success.
|
||||
ValidateS3Credential(accessKey, secretKey string) (identityName string, identity interface{}, err error)
|
||||
// GetCredentialByAccessKey looks up a credential by access key.
|
||||
// Returns the identity name, identity object, and secret key.
|
||||
// Used for verifying Bearer tokens signed with a specific credential.
|
||||
GetCredentialByAccessKey(accessKey string) (identityName string, identity interface{}, secretKey string, err error)
|
||||
}
|
||||
|
||||
// VendedCredentials are short-lived S3 credentials scoped to one table.
|
||||
type VendedCredentials struct {
|
||||
AccessKeyID string
|
||||
@@ -40,12 +53,13 @@ type CredentialVendor interface {
|
||||
|
||||
// Server implements the Lance Namespace REST spec.
|
||||
type Server struct {
|
||||
filerClient FilerClient
|
||||
tablesManager *s3tables.Manager
|
||||
authenticator S3Authenticator
|
||||
credentialVendor CredentialVendor
|
||||
s3Endpoint string
|
||||
s3Region string
|
||||
filerClient FilerClient
|
||||
tablesManager *s3tables.Manager
|
||||
authenticator S3Authenticator
|
||||
credentialValidator CredentialValidator
|
||||
credentialVendor CredentialVendor
|
||||
s3Endpoint string
|
||||
s3Region string
|
||||
}
|
||||
|
||||
// NewServer creates a Lance namespace server over the given filer.
|
||||
@@ -63,6 +77,11 @@ func NewServer(filerClient FilerClient, authenticator S3Authenticator) *Server {
|
||||
}
|
||||
}
|
||||
|
||||
// SetCredentialValidator sets the credential validator for OAuth token support.
|
||||
func (s *Server) SetCredentialValidator(cv CredentialValidator) {
|
||||
s.credentialValidator = cv
|
||||
}
|
||||
|
||||
// SetCredentialVendor enables storage_options credential vending for clients
|
||||
// that ask for it with vend_credentials.
|
||||
func (s *Server) SetCredentialVendor(vendor CredentialVendor) {
|
||||
@@ -87,6 +106,9 @@ func (s *Server) SetS3Region(region string) {
|
||||
func (s *Server) RegisterRoutes(router *mux.Router) {
|
||||
router.Use(loggingMiddleware)
|
||||
|
||||
// OAuth2 token endpoint - no auth needed (this IS the auth endpoint)
|
||||
router.HandleFunc("/oauth/token", s.handleOAuthTokens).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/v1/namespace/{id}/create", s.Auth(s.handleCreateNamespace)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/v1/namespace/{id}/list", s.Auth(s.handleListNamespaces)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/v1/namespace/{id}/describe", s.Auth(s.handleDescribeNamespace)).Methods(http.MethodPost)
|
||||
@@ -124,7 +146,7 @@ func (s *Server) RegisterRoutes(router *mux.Router) {
|
||||
}
|
||||
|
||||
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
glog.V(2).Infof("lance: no route for %s %s", r.Method, r.RequestURI)
|
||||
glog.V(2).Infof("lance: no route for %s %s", r.Method, r.URL.EscapedPath())
|
||||
writeError(w, r, http.StatusNotFound, codeUnsupported, "no such operation")
|
||||
})
|
||||
|
||||
@@ -138,16 +160,52 @@ func (s *Server) handleUnsupported(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
glog.V(2).Infof("lance request: %s %s from %s", r.Method, r.RequestURI, r.RemoteAddr)
|
||||
glog.V(2).Infof("lance request: %s %s from %s", r.Method, r.URL.EscapedPath(), r.RemoteAddr)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Auth authenticates the caller and puts the identity in the request context.
|
||||
// The Lance spec maps identity onto the same headers the S3 authenticator
|
||||
// already understands, so SigV4 and bearer tokens both keep working.
|
||||
// Lance clients authenticate the catalog with a Bearer token or an x-api-key
|
||||
// header; the S3 authenticator stays for callers that can SigV4-sign.
|
||||
func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// A request carrying a Bearer token is a Lance REST client. An invalid
|
||||
// or expired token gets 401 immediately; falling through to the S3
|
||||
// authenticator would parse the header as SigV4 and fail with a
|
||||
// different error that clients cannot act on.
|
||||
// The auth scheme is case-insensitive (RFC 7235).
|
||||
if strings.HasPrefix(strings.ToLower(r.Header.Get("Authorization")), "bearer ") {
|
||||
if identityName, identity, ok := s.authenticateBearer(r); ok {
|
||||
ctx := r.Context()
|
||||
ctx = s3_constants.SetIdentityNameInContext(ctx, identityName)
|
||||
if identity != nil {
|
||||
ctx = s3_constants.SetIdentityInContext(ctx, identity)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeError(w, r, http.StatusUnauthorized, codeUnauthenticated, "Bearer token is invalid or expired")
|
||||
return
|
||||
}
|
||||
|
||||
if apiKey := r.Header.Get("x-api-key"); apiKey != "" {
|
||||
if identityName, identity, ok := s.authenticateApiKey(apiKey); ok {
|
||||
ctx := r.Context()
|
||||
ctx = s3_constants.SetIdentityNameInContext(ctx, identityName)
|
||||
if identity != nil {
|
||||
ctx = s3_constants.SetIdentityInContext(ctx, identity)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
writeError(w, r, http.StatusUnauthorized, codeUnauthenticated, "invalid x-api-key")
|
||||
return
|
||||
}
|
||||
|
||||
if s.authenticator == nil {
|
||||
writeError(w, r, http.StatusUnauthorized, codeUnauthenticated, "authentication required")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user