diff --git a/Makefile b/Makefile index e33c92b56..8545c0cb8 100644 --- a/Makefile +++ b/Makefile @@ -311,7 +311,7 @@ require-%: install-build-deps: install-protoc-gen-gofast install-protoc install-statik install-stringer install-peg install-statik: - go get -u github.com/rakyll/statik + go install github.com/rakyll/statik@latest install-stringer: GO111MODULE=off $(GO) get -u golang.org/x/tools/cmd/stringer diff --git a/api_test.go b/api_test.go index d42aca667..f57bba824 100644 --- a/api_test.go +++ b/api_test.go @@ -4,17 +4,23 @@ package pilosa_test import ( "bytes" "context" + "encoding/hex" "errors" "fmt" + "io" "math" "math/rand" + "os" + "path/filepath" "reflect" "sort" "strings" "testing" "time" + "github.com/golang-jwt/jwt" pilosa "github.com/molecula/featurebase/v2" + "github.com/molecula/featurebase/v2/authn" "github.com/molecula/featurebase/v2/boltdb" "github.com/molecula/featurebase/v2/http" "github.com/molecula/featurebase/v2/server" @@ -1437,3 +1443,215 @@ func TestAPI_RBFDebugInfo(t *testing.T) { t.Fatal("expected info") } } + +// makeUser makes an authnUserInfo from groups and a name and a secret key +func makeUser(t *testing.T, groups []authn.Group, name, secret string) *authn.UserInfo { + tkn := jwt.New(jwt.SigningMethodHS256) + claims := tkn.Claims.(jwt.MapClaims) + groupString, err := authn.ToGob64(groups) + if err != nil { + t.Fatalf("gobbing groups %v", err) + } + claims["molecula-idp-groups"] = groupString + claims["oid"] = "42" + claims["name"] = name + secretKey, _ := hex.DecodeString(secret) + + validToken, err := tkn.SignedString(secretKey) + if err != nil { + t.Fatalf("signing string %v", err) + } + validToken = "Bearer " + validToken + + return &authn.UserInfo{ + UserID: "fake" + name, + UserName: name, + Groups: groups, + Token: validToken, + Expiry: time.Time{}, + } +} + +func TestAuth_MultiNode(t *testing.T) { + + // create permissions file + permissions := ` +"user-groups": + "dca35310-ecda-4f23-86cd-876aee55906b": + "test": "read" + "dca35310-ecda-4f23-86cd-876aee55906f": + "test": "write" +admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` + + // authentication on + auth := server.Auth{ + Enable: true, + ClientId: "e9088663-eb08-41d7-8f65-efb5f54bbb71", + ClientSecret: "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + AuthorizeURL: "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/token", + GroupEndpointURL: "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true", + LogoutURL: "https://login.microsoftonline.com/common/oauth2/v2.0/logout", + Scopes: []string{"https://graph.microsoft.com/.default", "offline_access"}, + SecretKey: "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + PermissionsFile: writeTestFile(t, "permissions.yaml", permissions), + QueryLogPath: writeTestFile(t, "queryLog.log", ""), + } + + config := server.NewConfig() + config.Auth = auth + + // set up TLS certificates + localhostCert := `-----BEGIN CERTIFICATE----- +MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zANBgkqhkiG9w0BAQsFADAS +MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw +MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB +iQKBgQDuLnQAI3mDgey3VBzWnB2L39JUU4txjeVE6myuDqkM/uGlfjb9SjY1bIw4 +iA5sBBZzHi3z0h1YV8QPuxEbi4nW91IJm2gsvvZhIrCHS3l6afab4pZBl2+XsDul +rKBxKKtD1rGxlG4LjncdabFn9gvLZad2bSysqz/qTAUStTvqJQIDAQABo2gwZjAO +BgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUw +AwEB/zAuBgNVHREEJzAlggtleGFtcGxlLmNvbYcEfwAAAYcQAAAAAAAAAAAAAAAA +AAAAATANBgkqhkiG9w0BAQsFAAOBgQCEcetwO59EWk7WiJsG4x8SY+UIAA+flUI9 +tyC4lNhbcF2Idq9greZwbYCqTTTr2XiRNSMLCOjKyI7ukPoPjo16ocHj+P3vZGfs +h1fIw3cSS2OolhloGw/XM6RWPWtPAlGykKLciQrBru5NAPvCMsb/I1DAceTiotQM +fblo6RBxUQ== +-----END CERTIFICATE-----` + + localhostKey := `-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQDuLnQAI3mDgey3VBzWnB2L39JUU4txjeVE6myuDqkM/uGlfjb9 +SjY1bIw4iA5sBBZzHi3z0h1YV8QPuxEbi4nW91IJm2gsvvZhIrCHS3l6afab4pZB +l2+XsDulrKBxKKtD1rGxlG4LjncdabFn9gvLZad2bSysqz/qTAUStTvqJQIDAQAB +AoGAGRzwwir7XvBOAy5tM/uV6e+Zf6anZzus1s1Y1ClbjbE6HXbnWWF/wbZGOpet +3Zm4vD6MXc7jpTLryzTQIvVdfQbRc6+MUVeLKwZatTXtdZrhu+Jk7hx0nTPy8Jcb +uJqFk541aEw+mMogY/xEcfbWd6IOkp+4xqjlFLBEDytgbIECQQDvH/E6nk+hgN4H +qzzVtxxr397vWrjrIgPbJpQvBsafG7b0dA4AFjwVbFLmQcj2PprIMmPcQrooz8vp +jy4SHEg1AkEA/v13/5M47K9vCxmb8QeD/asydfsgS5TeuNi8DoUBEmiSJwma7FXY +fFUtxuvL7XvjwjN5B30pNEbc6Iuyt7y4MQJBAIt21su4b3sjXNueLKH85Q+phy2U +fQtuUE9txblTu14q3N7gHRZB4ZMhFYyDy8CKrN2cPg/Fvyt0Xlp/DoCzjA0CQQDU +y2ptGsuSmgUtWj3NM9xuwYPm+Z/F84K6+ARYiZ6PYj013sovGKUFfYAqVXVlxtIX +qyUBnu3X9ps8ZfjLZO7BAkEAlT4R5Yl6cGhaJQYZHOde3JEMhNRcVFMO8dJDaFeo +f9Oeos0UUothgiDktdQHxdNEwLjQf7lJJBzV+5OtwswCWA== +-----END RSA PRIVATE KEY-----` + + config.TLS.CertificateKeyPath = writeTestFile(t, "certKey.pem", localhostKey) + config.TLS.CertificatePath = writeTestFile(t, "cert.pem", localhostCert) + + c := test.MustRunCluster(t, 3, + []server.CommandOption{ + server.OptCommandServerOptions( + pilosa.OptServerNodeID("node0"), + pilosa.OptServerClusterHasher(&test.ModHasher{}), + ), + server.OptCommandConfig(config), + }, + []server.CommandOption{ + server.OptCommandServerOptions( + pilosa.OptServerNodeID("node1"), + pilosa.OptServerClusterHasher(&test.ModHasher{}), + ), + server.OptCommandConfig(config), + }, + []server.CommandOption{ + server.OptCommandServerOptions( + pilosa.OptServerNodeID("node2"), + pilosa.OptServerClusterHasher(&test.ModHasher{}), + ), + server.OptCommandConfig(config), + }, + ) + defer c.Close() + + adminCtx := context.WithValue( + context.Background(), + "userinfo", + makeUser(t, []authn.Group{{GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "adminGroup"}}, "admin", config.Auth.SecretKey), + ) + readCtx := context.WithValue( + context.Background(), + "userinfo", + makeUser(t, []authn.Group{{GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: "readGroup"}}, "reader", config.Auth.SecretKey), + ) + writeCtx := context.WithValue( + context.Background(), + "userinfo", + makeUser(t, []authn.Group{{GroupID: "dca35310-ecda-4f23-86cd-876aee55906f", GroupName: "writeGroup"}}, "writer", config.Auth.SecretKey), + ) + + primaryAPI := c.GetPrimary().API + + // needs internal/cluster/message + indexName := "test" + _, err := primaryAPI.CreateIndex(adminCtx, indexName, pilosa.IndexOptions{}) + if err != nil { + t.Fatalf("creating index: %v", err) + } + // needs internal/translate/data + fieldName := "f" + _, err = primaryAPI.CreateField(adminCtx, indexName, fieldName, pilosa.OptFieldTypeSet(pilosa.DefaultCacheType, 100)) + if err != nil { + t.Fatalf("creating field: %v", err) + } + + _, err = primaryAPI.Query(readCtx, &pilosa.QueryRequest{ + Index: indexName, + Query: fmt.Sprintf(`Set(1, %s=1)`, fieldName), + }) + if err == nil { + t.Fatalf("readCtx should not be able to set bits") + } + + _, err = primaryAPI.Query(writeCtx, &pilosa.QueryRequest{ + Index: indexName, + Query: fmt.Sprintf(`Set(1, %s=1)`, fieldName), + }) + if err != nil { + t.Fatalf("writeCtx should be able to set bits: %v", err) + } + + _, err = primaryAPI.Query(adminCtx, &pilosa.QueryRequest{ + Index: indexName, + Query: fmt.Sprintf(`Set(1, %s=1)`, fieldName), + }) + if err != nil { + t.Fatalf("adminCtx should be able to set bits: %v", err) + } + + _, err = primaryAPI.Query(readCtx, &pilosa.QueryRequest{ + Index: indexName, + Query: fmt.Sprintf(`Count(Row(%s=1))`, fieldName), + }) + if err != nil { + t.Fatalf("readCtx should be able read: %v", err) + } + + _, err = primaryAPI.Query(writeCtx, &pilosa.QueryRequest{ + Index: indexName, + Query: fmt.Sprintf(`Count(Row(%s=1))`, fieldName), + }) + if err != nil { + t.Fatalf("writeCtx should be able read: %v", err) + } + + _, err = primaryAPI.Query(adminCtx, &pilosa.QueryRequest{ + Index: indexName, + Query: fmt.Sprintf(`Count(Row(%s=1))`, fieldName), + }) + if err != nil { + t.Fatalf("adminCtx should be able read: %v", err) + } +} + +func writeTestFile(t *testing.T, filename, content string) string { + t.Helper() + fname := filepath.Join(t.TempDir(), filename) + f, err := os.Create(fname) + if err != nil { + t.Fatalf("could not create file %v with err %v", filename, err) + } + _, err = io.WriteString(f, content) + if err != nil { + t.Fatalf("could not write string %v", err) + } + defer f.Close() + return fname +} diff --git a/authn/authenticate.go b/authn/authenticate.go index a5567842b..73b325055 100644 --- a/authn/authenticate.go +++ b/authn/authenticate.go @@ -4,7 +4,9 @@ package authn import ( - "context" + "bytes" + "encoding/base64" + "encoding/gob" "encoding/hex" "encoding/json" "fmt" @@ -13,20 +15,20 @@ import ( "time" "github.com/golang-jwt/jwt" - "github.com/gorilla/securecookie" "github.com/molecula/featurebase/v2/logger" "github.com/pkg/errors" "golang.org/x/oauth2" ) +func init() { + gob.Register([]Group{}) +} + // Auth holds state, configuration, and utilities needed for authentication. type Auth struct { logger logger.Logger cookieName string - refreshWithin time.Duration - hashKey []byte - blockKey []byte - secure *securecookie.SecureCookie + secretKey []byte groupEndpoint string logoutEndpoint string fbURL string // fbURL is the domain FB is hosted on, used for post logout redirection @@ -34,11 +36,10 @@ type Auth struct { } // NewAuth instantiates and returns a new Auth struct -func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenURL, groupEndpoint, logout, clientID, clientSecret, hashKey, blockKey string) (*Auth, error) { - auth := &Auth{ +func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenURL, groupEndpoint, logout, clientID, clientSecret, secretKey string) (auth *Auth, err error) { + auth = &Auth{ logger: logger, cookieName: "molecula-chip", - refreshWithin: 15 * time.Minute, groupEndpoint: groupEndpoint, logoutEndpoint: logout, fbURL: url, @@ -53,72 +54,111 @@ func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenUR }, }, } - var err error - if auth.hashKey, err = decodeHex(hashKey); err != nil { - return nil, errors.Wrap(err, "decoding hash key") - } - if auth.blockKey, err = decodeHex(blockKey); err != nil { - return nil, errors.Wrap(err, "decoding block key") + if auth.secretKey, err = decodeHex(secretKey); err != nil { + return nil, errors.Wrap(err, "decoding secret key") } - auth.secure = securecookie.New(auth.hashKey, auth.blockKey) - return auth, nil } -// AuthContext holds the value of an authenticated user's cookie -type AuthContext struct { - UserID string - UserName string - GroupMembership []Group - Token *oauth2.Token +func (a Auth) SecretKey() []byte { + return a.secretKey +} + +// UserInfo holds the information about the user from the token +type UserInfo struct { + UserID string `json:"userid"` + UserName string `json:"username"` + Groups []Group `json:"groups"` + Expiry time.Time `json:"expiry"` + Token string `json:"token"` } // Group holds group information for an authenticated user type Group struct { - UserID string GroupID string `json:"id"` GroupName string `json:"displayName"` } -// Groups holds a slice of Group informations for marshalling from Json +// ToGob64 encodes a []Group to a string, returning string and nil on success (todd's idea) +// it has to be a string bc we're using it in a jwt.MapClaims which needs string-y things +func ToGob64(m []Group) (string, error) { + var b bytes.Buffer + if err := gob.NewEncoder(&b).Encode(m); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(b.Bytes()), nil +} + +// FromGob64 converts a previously encoded []Group from a string to a []Group +// it has to be a string bc we're using it in a jwt.MapClaims which needs string-y things +func FromGob64(gobbed string) ([]Group, error) { + m := []Group{} + by, err := base64.StdEncoding.DecodeString(gobbed) + if err != nil { + return nil, err + } + b := bytes.Buffer{} + b.Write(by) + d := gob.NewDecoder(&b) + err = d.Decode(&m) + if err != nil { + return nil, err + } + return m, nil +} + +// Groups holds a slice of Group for marshalling from Json type Groups struct { Groups []Group `json:"value"` } -// UserInfo holds user information for an authenticated user -type UserInfo struct { - UserID string `json:"userid"` - UserName string `json:"username"` -} - -// Authenticate reads the authentication cookie from a request, returning the -// user's group memberships on success. If the cookie is not present or has expired, -// Authenticate redirects the user to sign in. If the cookie is within the -// refresh window of expiring, the cookie is refreshed, and the updated group -// membership is returned. -func (a *Auth) Authenticate(w http.ResponseWriter, r *http.Request) ([]Group, error) { - cookie, err := a.readCookie(w, r) - if err != nil { - http.Redirect(w, r, "/signin", http.StatusTemporaryRedirect) - return nil, err +// Authenticate takes in a bearer token `bearer` and returns UserInfo from that token +func (a *Auth) Authenticate(bearer string) (*UserInfo, error) { + // parse the bearer token into a jwt.Token + token, err := jwt.Parse(bearer, 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 a.secretKey, nil + }) + if token == nil || token.Claims == nil || err != nil || !token.Valid { + return nil, errors.Wrap(err, fmt.Sprintf("%#v parsing jwt claims from access tokens", token)) } - if cookie.Token.Expiry.Before(time.Now().Add(a.refreshWithin)) { - err = a.refreshToken(w, cookie) - if err != nil { - a.logger.Errorf("refreshing access token: ", err) - if cookie.Token.Expiry.Before(time.Now()) { - http.Redirect(w, r, "/signin", http.StatusTemporaryRedirect) - return nil, err + userInfo := UserInfo{} + // check that token does not expire now + switch claimType := token.Claims.(type) { + case jwt.MapClaims: + if exp, ok := claimType["exp"]; ok { + var e int64 + switch expType := exp.(type) { + case float64: + e = int64(expType) + case json.Number: + e, _ = expType.Int64() + } + if e <= time.Now().Unix() { + return nil, fmt.Errorf("token expired") } } - } - if len(cookie.GroupMembership) == 0 { - return nil, errors.New("user is not part of any groups in identity provider") - } - return cookie.GroupMembership, nil + userInfo.UserID = claimType["oid"].(string) + userInfo.UserName = claimType["name"].(string) + userInfo.Token = bearer + + g := claimType["molecula-idp-groups"].(string) + groups, err := FromGob64(g) + if err != nil { + return nil, errors.Wrap(err, "decoding groups") + } + userInfo.Groups = groups + + default: + return nil, fmt.Errorf("could not parse jwt claims of type %T, expected jwt.MapClaims", claimType) + } + + return &userInfo, nil } // Login redirects a user to login to their configured oAuth authorize endpoint @@ -129,46 +169,41 @@ func (a *Auth) Login(w http.ResponseWriter, r *http.Request) { // Logout clears out user cookie and redirects user to IdP's logout endpoint func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, a.getEmptyCookie()) + http.SetCookie(w, &http.Cookie{ + Name: a.cookieName, + Value: "", + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Expires: time.Unix(0, 0), + }) redirect := fmt.Sprintf("%s?post_logout_redirect_uri=%s/", a.logoutEndpoint, a.fbURL) http.Redirect(w, r, redirect, http.StatusTemporaryRedirect) } // Redirect handles the oAuth /redirect endpoint. It gets user information from -// the identity provider and sets a secure cookie holding the user information. +// the identity provider and sets a secure cookie holding the user information +// signed by featurebase. func (a *Auth) Redirect(w http.ResponseWriter, r *http.Request) { code := r.FormValue("code") token, err := a.getToken(r, code) if err != nil { a.logger.Warnf("getting token from IdP: %+v", err) - http.Error(w, "Bad Request: 400", http.StatusBadRequest) + http.Error(w, "Bad Request", http.StatusBadRequest) return } - cv, err := a.newAuthContext(token) - if err != nil || cv == nil { - a.logger.Warnf("creating cookie: %+v", err) - http.Error(w, "Bad Request: 400", http.StatusBadRequest) - return - } - - a.setCookie(w, cv) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) -} - -// GetUserInfo reads user's cookie and returns their username and userId -func (a *Auth) GetUserInfo(w http.ResponseWriter, r *http.Request) *UserInfo { - var resp UserInfo - cookie, err := a.readCookie(w, r) + // with vitamin A! + enrichedTkn, err := a.addGroupMembership(token.AccessToken) if err != nil { - a.logger.Warnf("was not able to read cookie for req: %+v", r) - return &resp + a.logger.Warnf("enriching token with group membership: %+v", err) + http.Error(w, "Bad Request", http.StatusBadRequest) + return } - return &UserInfo{ - UserID: cookie.UserID, - UserName: cookie.UserName, - } + a.setCookie(w, enrichedTkn, token.Expiry) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) } // getToken exhanges authorization code for an oAuth2 token @@ -180,124 +215,88 @@ func (a *Auth) getToken(r *http.Request, code string) (*oauth2.Token, error) { return token, nil } -// newAuthContext parses a jwt `token` and returns relevant information in a cookie value struct -func (a *Auth) newAuthContext(token *oauth2.Token) (*AuthContext, error) { - if token == nil { - return nil, errors.New("baking cookie due to nil token") - } - if token.AccessToken == "" { - return nil, errors.New("no access token provided") - } - - // We are using ParseUnverified here because we're using the OAuth2.0 authZ code flow - // which assumes that the IdP gives good responses. This means that if the IdP is - // insecure, then we are too. But that's the way OAuth works, unfortunately. - // Also, we assume the jwt is not tampered with bc we communicate with the IdP over HTTPS only. - accessParsed, _, err := new(jwt.Parser).ParseUnverified(token.AccessToken, jwt.MapClaims{}) - if accessParsed == nil || accessParsed.Claims == nil || err != nil { - return nil, errors.Wrap(err, fmt.Sprintf("%v parsing jwt claims from access tokens", accessParsed)) - } - claims := accessParsed.Claims.(jwt.MapClaims) - - groups, err := a.getGroupMembership(token) +// addGroupMembership is only called in `a.Redirect`. It adds groups to a jwt's +// claims, and signs it using `a.secretKey`. +func (a *Auth) addGroupMembership(token string) (string, error) { + g, err := a.getGroups(token) if err != nil { - return nil, errors.Wrap(err, "getting group membership") + return "", err } - // not needed at this point in the logic and makes the encoded cookie too large - token.AccessToken = "" - return &AuthContext{ - UserID: claims["oid"].(string), - UserName: claims["name"].(string), - GroupMembership: groups.Groups, - Token: token, - }, nil + + // parse token into jwt + unenriched, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{}) + if unenriched == nil || unenriched.Claims == nil || err != nil { + return "", errors.Wrap(err, fmt.Sprintf("%v parsing jwt claims from access tokens", token)) + } + + enriched := jwt.New(jwt.SigningMethodHS256) + enriched.Claims = unenriched.Claims + var tokenStr string + // parse groups into string format + switch claims := enriched.Claims.(type) { + case jwt.MapClaims: + groupString, err := ToGob64(g) + if err != nil { + return "", errors.Wrap(err, "failed to serialize groups") + } + + // stick it into jwt claims + claims["molecula-idp-groups"] = groupString + + // get stringified and signed jwt + tokenStr, err = enriched.SignedString(a.secretKey) + if err != nil { + return "", errors.Wrap(err, "signing jwt") + } + + default: + return "", fmt.Errorf("could not parse jwt claims of type %T, expected jwt.MapClaims", claims) + } + + return tokenStr, nil } -// getGroupMembership uses a oauth2 token to retrieve group membership information from IdP -func (a *Auth) getGroupMembership(token *oauth2.Token) (Groups, error) { +// getGroups gets the group membership for a given token from configured IdP +func (a *Auth) getGroups(token string) ([]Group, error) { var groups Groups req, err := http.NewRequest("GET", a.groupEndpoint, nil) if err != nil { - return groups, errors.Wrap(err, "creating new request to group endpoint") + return groups.Groups, errors.Wrap(err, "creating new request to group endpoint") } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) response, err := http.DefaultClient.Do(req) if err != nil { - return groups, errors.Wrap(err, "getting group membership info") + return groups.Groups, errors.Wrap(err, "getting group membership info") } defer response.Body.Close() rawGroups, err := io.ReadAll(response.Body) if err != nil { - return groups, errors.Wrap(err, "failed reading group membership response") + return groups.Groups, errors.Wrap(err, "failed reading group membership response") } if err = json.Unmarshal(rawGroups, &groups); err != nil { - return groups, errors.Wrap(err, "failed unmarshalling group membership response") + return groups.Groups, errors.Wrap(err, "failed unmarshalling group membership response") } - return groups, nil + return groups.Groups, nil } -// readCookie decodes an encrypted and signed cookie and returns the contained info -func (a *Auth) readCookie(w http.ResponseWriter, r *http.Request) (*AuthContext, error) { - cookie, err := r.Cookie(a.cookieName) - if err != nil { - return nil, errors.Wrap(err, "cookie not found") - } - - var value AuthContext - err = a.secure.Decode(a.cookieName, cookie.Value, &value) - if err != nil { - http.SetCookie(w, a.getEmptyCookie()) - return nil, errors.Wrap(err, "decoding cookie") - } - - return &value, nil -} - -func (a *Auth) setCookie(w http.ResponseWriter, cookie *AuthContext) error { - encoded, err := a.secure.Encode(a.cookieName, cookie) - if err != nil { - return errors.Wrap(err, "encoding AuthContext") - - } +func (a *Auth) setCookie(w http.ResponseWriter, token string, expiry time.Time) error { http.SetCookie(w, &http.Cookie{ Name: a.cookieName, - Value: encoded, + Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, - Expires: cookie.Token.Expiry, + Expires: expiry, }) return nil } -func (a *Auth) refreshToken(w http.ResponseWriter, cookie *AuthContext) error { - if cookie.Token.RefreshToken == "" { - return errors.New("no refresh token found, check auth scopes to see if refresh tokens are being provided by your IdP") - } - tokenSource := a.oAuthConfig.TokenSource(context.Background(), cookie.Token) - newToken, err := tokenSource.Token() - if err != nil { - return errors.Wrap(err, "refreshing token") - } - - if newToken.Expiry != cookie.Token.Expiry { - cv, err := a.newAuthContext(newToken) - if err != nil { - return errors.Wrap(err, "creating cookie value from token") - } - - a.setCookie(w, cv) - } - - return nil -} - func decodeHex(hexstr string) ([]byte, error) { data, err := hex.DecodeString(hexstr) if err != nil { @@ -308,15 +307,3 @@ func decodeHex(hexstr string) ([]byte, error) { } return data, nil } - -func (a *Auth) getEmptyCookie() *http.Cookie { - return &http.Cookie{ - Name: a.cookieName, - Value: "", - Path: "/", - Secure: true, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - } - -} diff --git a/authn/authenticate_internal_test.go b/authn/authenticate_internal_test.go index 7af62092e..f937510d5 100644 --- a/authn/authenticate_internal_test.go +++ b/authn/authenticate_internal_test.go @@ -8,7 +8,6 @@ import ( "time" "github.com/molecula/featurebase/v2/logger" - "golang.org/x/oauth2" ) func TestAuth(t *testing.T) { @@ -35,37 +34,14 @@ func TestAuth(t *testing.T) { ClientID, ClientSecret, Key, - Key, ) if err != nil { t.Errorf("building auth object%s", err) } - tokenNoAT := oauth2.Token{ - TokenType: "Bearer", - RefreshToken: "abcdef", - Expiry: time.Now().Add(time.Hour), - } - tokenAT := oauth2.Token{ - TokenType: "Bearer", - RefreshToken: "abcdef", - AccessToken: "aasdf", - Expiry: time.Now().Add(time.Hour), - } - grp := Group{ - UserID: "snowstorm", - GroupID: "abcd123-A", - GroupName: "Romantic Painters", - } - validCV := AuthContext{ - UserID: "snowstorm", - UserName: "J.M.W. Turner", - GroupMembership: []Group{grp}, - Token: &tokenAT, - } t.Run("SetCookie", func(t *testing.T) { w := httptest.NewRecorder() - err := a.setCookie(w, &validCV) + err := a.setCookie(w, "a cookie value", time.Now().Add(time.Hour)) if err != nil { t.Fatalf("expected no errors, got: %v", err) } @@ -79,12 +55,6 @@ func TestAuth(t *testing.T) { } }) - t.Run("GetEmptyCookie", func(t *testing.T) { - c := a.getEmptyCookie() - if c.Value != "" { - t.Fatalf("expected empty cookie, got: %+v", c.Value) - } - }) t.Run("KeyLength", func(t *testing.T) { _, err := NewAuth( logger.NewStandardLogger(os.Stdout), @@ -96,25 +66,10 @@ func TestAuth(t *testing.T) { LogoutURL, ClientID, ClientSecret, - Key, ShortKey, ) - if err == nil || !strings.Contains(err.Error(), "decoding block key") { - t.Fatalf("expected error decoding block key got: %v", err) + if err == nil || !strings.Contains(err.Error(), "decoding secret key") { + t.Fatalf("expected error decoding secret key got: %v", err) } }) - t.Run("NewAuthContext-BadAccessToken", func(t *testing.T) { - _, err := a.newAuthContext(&tokenAT) - if err == nil || !strings.Contains(err.Error(), "jwt claims") { - t.Fatalf("expected failure regarding jwt claims, got: %v", err) - } - - }) - t.Run("AuthContext-NoAccessToken", func(t *testing.T) { - _, err := a.newAuthContext(&tokenNoAT) - if err == nil || !strings.Contains(err.Error(), "access token") { - t.Fatalf("expected failure regarding access token, got: %v", err) - } - }) - } diff --git a/authz/authorization.go b/authz/authorization.go index 727d4db3f..a2127f33d 100644 --- a/authz/authorization.go +++ b/authz/authorization.go @@ -68,7 +68,8 @@ func (p *GroupPermissions) ReadPermissionsFile(permsFile io.Reader) (err error) return } -func (p *GroupPermissions) GetPermissions(groups []authn.Group, index string) (permission Permission, errors error) { +func (p *GroupPermissions) GetPermissions(user *authn.UserInfo, index string) (permission Permission, errors error) { + groups := user.Groups if admin := p.IsAdmin(groups); admin { return Admin, nil } @@ -88,7 +89,7 @@ func (p *GroupPermissions) GetPermissions(groups []authn.Group, index string) (p if perm, ok := p.Permissions[group.GroupID][index]; ok { allPermissions[perm] = true } else { - return None, fmt.Errorf("user %s does not have permission to index %s", group.UserID, index) + return None, fmt.Errorf("user %s does not have permission to index %s", user.UserID, index) } } else { groupsDenied = append(groupsDenied, group.GroupID) diff --git a/authz/authorization_test.go b/authz/authorization_test.go index b8b9f5491..0b0f9dbbe 100644 --- a/authz/authorization_test.go +++ b/authz/authorization_test.go @@ -107,17 +107,15 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` // initializes groups that are returned from identity provider groupName := "name" - userId := "user-id" groupsList1 := []authn.Group{} groupsList2 := []authn.Group{{ - UserID: userId, GroupID: "fake-group", GroupName: groupName}} groupsList3 := []authn.Group{ - {UserID: userId, GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: groupName}, - {UserID: userId, GroupID: "dca35310-ecda-4f23-86cd-876aee559900", GroupName: groupName}, + {GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: groupName}, + {GroupID: "dca35310-ecda-4f23-86cd-876aee559900", GroupName: groupName}, } - groupsList4 := []authn.Group{{UserID: userId, GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: groupName}} + groupsList4 := []authn.Group{{GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: groupName}} tests := []struct { yamlData string @@ -187,7 +185,7 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` t.Errorf("Error: %s", err) } - p1, err := p.GetPermissions(test.groups, test.index) + p1, err := p.GetPermissions(&authn.UserInfo{Groups: test.groups}, test.index) if p1 != test.userAccess { t.Errorf("expected permission to be %s, but got %s", test.userAccess, p1) @@ -206,11 +204,11 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` func TestAuth_IsAdmin(t *testing.T) { group1 := []authn.Group{ - {UserID: "admin-user-id", GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "admin-group"}, + {GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "admin-group"}, } group2 := []authn.Group{ - {UserID: "user-id", GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: "group-name"}, + {GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: "group-name"}, } groupPermissions := authz.GroupPermissions{ @@ -247,15 +245,15 @@ func TestAuth_IsAdmin(t *testing.T) { func TestAuth_GetAuthorizedIndexList(t *testing.T) { group1 := []authn.Group{ - {UserID: "user-id", GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: "group-name"}, + {GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: "group-name"}, } group2 := []authn.Group{ - {UserID: "admin-user-id", GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "admin-group"}, + {GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "admin-group"}, } group3 := []authn.Group{ - {UserID: "user-id", GroupID: "dca35310-ecda-4f23-86cd-876aee559900", GroupName: "group-name"}, + {GroupID: "dca35310-ecda-4f23-86cd-876aee559900", GroupName: "group-name"}, } p := authz.GroupPermissions{ diff --git a/ctl/server.go b/ctl/server.go index 799f56bf0..74f5ad888 100644 --- a/ctl/server.go +++ b/ctl/server.go @@ -115,8 +115,7 @@ func BuildServerFlags(cmd *cobra.Command, srv *server.Command) { flags.StringVar(&srv.Config.Auth.GroupEndpointURL, "auth.group-endpoint-url", srv.Config.Auth.GroupEndpointURL, "Identity Provider's Group endpoint URL.") flags.StringVar(&srv.Config.Auth.LogoutURL, "auth.logout-url", srv.Config.Auth.LogoutURL, "Identity Provider's Logout URL.") flags.StringSliceVar(&srv.Config.Auth.Scopes, "auth.scopes", srv.Config.Auth.Scopes, "Comma separated list of scopes obtained from IdP") - flags.StringVar(&srv.Config.Auth.HashKey, "auth.hash-key", srv.Config.Auth.HashKey, "First Secret for Auth.") - flags.StringVar(&srv.Config.Auth.BlockKey, "auth.block-key", srv.Config.Auth.BlockKey, "Second Secret for Auth.") + flags.StringVar(&srv.Config.Auth.SecretKey, "auth.secret-key", srv.Config.Auth.SecretKey, "Secret key used for auth.") flags.StringVar(&srv.Config.Auth.PermissionsFile, "auth.permissions", srv.Config.Auth.PermissionsFile, "Permissions' file with group authorization.") flags.StringVar(&srv.Config.Auth.QueryLogPath, "auth.query-log-path", srv.Config.Auth.QueryLogPath, "Path to log user queries") diff --git a/go.mod b/go.mod index 3b36cd0a8..f15095796 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/cespare/xxhash v1.1.0 github.com/davecgh/go-spew v1.1.1 github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dustin/go-humanize v1.0.0 // indirect github.com/felixge/fgprof v0.9.1 github.com/fsnotify/fsnotify v1.4.9 // indirect @@ -57,8 +58,9 @@ require ( golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c + golang.org/x/sys v0.0.0-20220111092808-5a964db01320 // indirect google.golang.org/grpc v1.28.0 - gopkg.in/yaml.v2 v2.3.0 + gopkg.in/yaml.v2 v2.4.0 modernc.org/mathutil v1.0.0 modernc.org/strutil v1.0.0 sigs.k8s.io/yaml v1.2.0 // indirect diff --git a/go.sum b/go.sum index 97504ff52..2778ff697 100644 --- a/go.sum +++ b/go.sum @@ -504,8 +504,9 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201014080544-cc95f250f6bc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71 h1:ikCpsnYR+Ew0vu99XlDp55lGgDJdIMx3f4a18jfse/s= golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220111092808-5a964db01320 h1:0jf+tOCoZ3LyutmCOWpVni1chK4VfFLhRsDK7MhqGRY= +golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -596,8 +597,8 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/http/client.go b/http/client.go index 6ef35992a..c9bf14068 100644 --- a/http/client.go +++ b/http/client.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/go-retryablehttp" pilosa "github.com/molecula/featurebase/v2" + "github.com/molecula/featurebase/v2/authn" "github.com/molecula/featurebase/v2/encoding/proto" "github.com/molecula/featurebase/v2/ingest" "github.com/molecula/featurebase/v2/logger" @@ -42,6 +43,9 @@ type InternalClient struct { retryableClient *retryablehttp.Client // the local node's API, used for operations that we can short-circuit that way api *pilosa.API + + // secret Key for auth across nodes + secretKey string } // NewInternalClient returns a new instance of InternalClient to connect to host. @@ -63,6 +67,14 @@ func NewInternalClient(host string, remoteClient *http.Client, opts ...InternalC type InternalClientOption func(c *InternalClient) +// WithSecretKey adds the secretKey used for inter-node communication when auth +// is enabled +func WithSecretKey(secretKey string) InternalClientOption { + return func(c *InternalClient) { + c.secretKey = secretKey + } +} + // WithClientRetryPeriod is the max amount of total time the client will // retry failed requests using exponential backoff. func WithClientRetryPeriod(period time.Duration) InternalClientOption { @@ -572,6 +584,12 @@ func (c *InternalClient) QueryNode(ctx context.Context, uri *pnet.URI, index str return nil, errors.Wrap(err, "creating request") } + uinfo := ctx.Value("userinfo") + if uinfo != nil { + token := uinfo.(*authn.UserInfo).Token + req.Header.Set("Authorization", token) + } + req.Header.Set("Content-Length", strconv.Itoa(len(buf))) req.Header.Set("Content-Type", "application/x-protobuf") req.Header.Set("Accept", "application/x-protobuf") @@ -1206,10 +1224,14 @@ func (c *InternalClient) SendMessage(ctx context.Context, uri *pnet.URI, msg []b if err != nil { return errors.Wrap(err, "making new request") } + req.Header.Set("Content-Type", "application/x-protobuf") req.Header.Set("User-Agent", "pilosa/"+pilosa.Version) req.Header.Set("Accept", "application/json") req.Header.Set("Connection", "keep-alive") + if c.secretKey != "" { + req.Header.Set("X-Feature-Key", c.secretKey) + } // Execute request. resp, err := c.executeRequest(req.WithContext(ctx)) diff --git a/http/handler.go b/http/handler.go index 60a9e4d2c..efd20d7e2 100644 --- a/http/handler.go +++ b/http/handler.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "crypto/tls" + "encoding/hex" "encoding/json" "expvar" "fmt" @@ -416,9 +417,9 @@ func newRouter(handler *Handler) http.Handler { router.HandleFunc("/index/{index}/field/", handler.chkAuthZ(handler.handlePostField, authz.Write)).Methods("POST").Name("PostField") router.HandleFunc("/index/{index}/field/{field}", handler.chkAuthZ(handler.handlePostField, authz.Write)).Methods("POST").Name("PostField") router.HandleFunc("/index/{index}/field/{field}", handler.chkAuthZ(handler.handleDeleteField, authz.Write)).Methods("DELETE").Name("DeleteField") - router.HandleFunc("/index/{index}/field/{field}/import", handler.chkAuthZ(handler.handlePostImport, authz.Read)).Methods("POST").Name("PostImport") + router.HandleFunc("/index/{index}/field/{field}/import", handler.chkAuthZ(handler.handlePostImport, authz.Write)).Methods("POST").Name("PostImport") router.HandleFunc("/index/{index}/field/{field}/mutex-check", handler.chkAuthZ(handler.handleGetMutexCheck, authz.Read)).Methods("GET").Name("GetMutexCheck") - router.HandleFunc("/index/{index}/field/{field}/import-roaring/{shard}", handler.chkAuthZ(handler.handlePostImportRoaring, authz.Read)).Methods("POST").Name("PostImportRoaring") + router.HandleFunc("/index/{index}/field/{field}/import-roaring/{shard}", handler.chkAuthZ(handler.handlePostImportRoaring, authz.Write)).Methods("POST").Name("PostImportRoaring") router.HandleFunc("/index/{index}/query", handler.chkAuthZ(handler.handlePostQuery, authz.Read)).Methods("POST").Name("PostQuery") router.HandleFunc("/info", handler.chkAuthZ(handler.handleGetInfo, authz.Admin)).Methods("GET").Name("GetInfo") router.HandleFunc("/recalculate-caches", handler.chkAuthZ(handler.handleRecalculateCaches, authz.Admin)).Methods("POST").Name("RecalculateCaches") @@ -444,14 +445,18 @@ func newRouter(handler *Handler) http.Handler { // /internal endpoints are for internal use only; they may change at any time. // DO NOT rely on these for external applications! - router.HandleFunc("/internal/cluster/message", handler.chkAuthN(handler.handlePostClusterMessage)).Methods("POST").Name("PostClusterMessage") + + // Truly used internally by featurebease + router.HandleFunc("/internal/cluster/message", handler.chkInternal(handler.handlePostClusterMessage)).Methods("POST").Name("PostClusterMessage") + router.HandleFunc("/internal/translate/data", handler.chkInternal(handler.handleGetTranslateData)).Methods("GET").Name("GetTranslateData") + router.HandleFunc("/internal/translate/data", handler.chkInternal(handler.handlePostTranslateData)).Methods("POST").Name("PostTranslateData") + + // other ones router.HandleFunc("/internal/fragment/block/data", handler.chkAuthN(handler.handleGetFragmentBlockData)).Methods("GET").Name("GetFragmentBlockData") router.HandleFunc("/internal/fragment/blocks", handler.chkAuthN(handler.handleGetFragmentBlocks)).Methods("GET").Name("GetFragmentBlocks") router.HandleFunc("/internal/fragment/data", handler.chkAuthN(handler.handleGetFragmentData)).Methods("GET").Name("GetFragmentData") router.HandleFunc("/internal/fragment/nodes", handler.chkAuthN(handler.handleGetFragmentNodes)).Methods("GET").Name("GetFragmentNodes") router.HandleFunc("/internal/partition/nodes", handler.chkAuthN(handler.handleGetPartitionNodes)).Methods("GET").Name("GetPartitionNodes") - router.HandleFunc("/internal/translate/data", handler.chkAuthN(handler.handleGetTranslateData)).Methods("GET").Name("GetTranslateData") - router.HandleFunc("/internal/translate/data", handler.chkAuthN(handler.handlePostTranslateData)).Methods("POST").Name("PostTranslateData") router.HandleFunc("/internal/translate/keys", handler.chkAuthN(handler.handlePostTranslateKeys)).Methods("POST").Name("PostTranslateKeys") router.HandleFunc("/internal/translate/ids", handler.chkAuthN(handler.handlePostTranslateIDs)).Methods("POST").Name("PostTranslateIDs") router.HandleFunc("/internal/index/{index}/field/{field}/mutex-check", handler.chkAuthN(handler.handleInternalGetMutexCheck)).Methods("GET").Name("InternalGetMutexCheck") @@ -540,11 +545,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.Handler.ServeHTTP(w, r) } +func (h *Handler) chkInternal(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if h.auth != nil { + secret, ok := r.Header["X-Feature-Key"] + decodedString, err := hex.DecodeString(secret[0]) + if err != nil || !ok || !bytes.Equal(decodedString, h.auth.SecretKey()) { + http.Error(w, errors.Wrap(err, "internal secret key validation failed").Error(), http.StatusUnauthorized) + return + } + } + handler.ServeHTTP(w, r) + } +} + func (h *Handler) chkAuthN(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if h.auth != nil { - if _, err := h.auth.Authenticate(w, r); err != nil { - http.Error(w, errors.Wrap(err, "authenticating").Error(), http.StatusBadRequest) + if _, err := h.auth.Authenticate(getToken(r)); err != nil { + http.Error(w, errors.Wrap(err, "authenticating").Error(), http.StatusUnauthorized) return } } @@ -556,9 +575,9 @@ func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http return func(w http.ResponseWriter, r *http.Request) { lperm := perm if h.auth != nil { - groups, err := h.auth.Authenticate(w, r) + uinfo, err := h.auth.Authenticate(getToken(r)) if err != nil { - http.Error(w, errors.Wrap(err, "authenticating").Error(), http.StatusBadRequest) + http.Error(w, errors.Wrap(err, "authenticating").Error(), http.StatusForbidden) return } @@ -568,7 +587,16 @@ func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http return } - uinfo := h.auth.GetUserInfo(w, r) + ctx := context.WithValue(r.Context(), contextKeyGroupMembership, uinfo.Groups) + + if h.permissions.IsAdmin(uinfo.Groups) { + ctx = context.WithValue(ctx, contextKeyPermission, authz.Admin) + handler.ServeHTTP(w, r.WithContext(ctx)) + return + } else if lperm == authz.Admin { + http.Error(w, "Insufficient permissions: user does not have admin permission", http.StatusForbidden) + return + } var queryString string queryRequest := r.Context().Value(contextKeyQueryRequest) @@ -591,14 +619,18 @@ func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http h.querylogger.Infof("User ID: %s, User Name: %s, Endpoint: %s, Index: %s, Query: %s, Err: %v", uinfo.UserID, uinfo.UserName, r.URL.Path, "indexName", queryString, err) } - ctx := context.WithValue(r.Context(), contextKeyGroupMembership, groups) indexName, ok := mux.Vars(r)["index"] if ok { - p, err := h.permissions.GetPermissions(groups, indexName) - ctx = context.WithValue(r.Context(), contextKeyPermission, p) - if err != nil || !p.Satisfies(lperm) { + p, err := h.permissions.GetPermissions(uinfo, indexName) + ctx = context.WithValue(ctx, contextKeyPermission, p) + if err != nil { w.Header().Add("Content-Type", "text/plain") - w.WriteHeader(http.StatusForbidden) + http.Error(w, errors.Wrap(err, "Insufficient Permissions").Error(), http.StatusForbidden) + return + } + if !p.Satisfies(lperm) { + w.Header().Add("Content-Type", "text/plain") + http.Error(w, fmt.Sprintf("Insufficient permissions: user has %s permissions, but request requires %s permission", p, lperm), http.StatusForbidden) return } } @@ -3551,10 +3583,11 @@ func (h *Handler) handleCheckAuthentication(w http.ResponseWriter, r *http.Reque http.Error(w, "", http.StatusNoContent) return } - groups, err := h.auth.Authenticate(w, r) - if groups == nil || err != nil { + uinfo, err := h.auth.Authenticate(getToken(r)) + + if uinfo == nil || err != nil { w.Header().Add("Content-Type", "text/plain") - w.WriteHeader(http.StatusForbidden) + http.Error(w, err.Error(), http.StatusUnauthorized) return } w.Header().Add("Content-Type", "text/plain") @@ -3572,7 +3605,14 @@ func (h *Handler) handleUserInfo(w http.ResponseWriter, r *http.Request) { http.Error(w, "", http.StatusNoContent) return } - if err := json.NewEncoder(w).Encode(h.auth.GetUserInfo(w, r)); err != nil { + uinfo, err := h.auth.Authenticate(getToken(r)) + if err != nil { + h.logger.Errorf("error authenticating: %v", err) + http.Error(w, err.Error(), http.StatusForbidden) + return + } + + if err := json.NewEncoder(w).Encode(uinfo); err != nil { h.logger.Errorf("writing user info: %s", err) } } @@ -3584,3 +3624,20 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { } h.auth.Logout(w, r) } + +// getToken gets the access token from the request, returning empty string on +// error +func getToken(r *http.Request) string { + if token, ok := r.Header["Authorization"]; ok && len(token) > 0 { + parts := strings.Split(token[0], "Bearer ") + if len(parts) != 2 { + return "" + } + return parts[1] + } + cookie, err := r.Cookie("molecula-chip") + if err != nil { + return "" + } + return cookie.Value +} diff --git a/http/handler_internal_test.go b/http/handler_internal_test.go index a073e7a38..e23af836c 100644 --- a/http/handler_internal_test.go +++ b/http/handler_internal_test.go @@ -15,15 +15,14 @@ import ( "testing" "time" - "github.com/gorilla/securecookie" + "github.com/golang-jwt/jwt" pilosa "github.com/molecula/featurebase/v2" "github.com/molecula/featurebase/v2/authn" - "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" "github.com/molecula/featurebase/v2/authz" "github.com/molecula/featurebase/v2/logger" "github.com/molecula/featurebase/v2/pql" - "golang.org/x/oauth2" ) // Test custom UnmarshalJSON for postIndexRequest object @@ -198,12 +197,10 @@ func TestAuthentication(t *testing.T) { GroupEndpointURL = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true" LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout" Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"} - HashKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" - BlockKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + SecretKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" ) - hashKey, _ := hex.DecodeString("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") - blockKey, _ := hex.DecodeString("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") + secretKey, _ := hex.DecodeString("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") a, err := authn.NewAuth( logger.NewStandardLogger(os.Stdout), @@ -215,97 +212,56 @@ func TestAuthentication(t *testing.T) { LogoutURL, ClientId, ClientSecret, - HashKey, - BlockKey, + SecretKey, ) if err != nil { t.Errorf("building auth object%s", err) } h := Handler{ - auth: a, + logger: logger.NewStandardLogger(os.Stdout), + querylogger: logger.NewStandardLogger(os.Stdout), + auth: a, } hOff := Handler{} + // make a valid token + tkn := jwt.New(jwt.SigningMethodHS256) + claims := tkn.Claims.(jwt.MapClaims) + groupString, _ := authn.ToGob64([]authn.Group{{GroupID: "thing", GroupName: "whatever"}}) + claims["molecula-idp-groups"] = groupString + claims["oid"] = "42" + claims["name"] = "todd" + validToken, err := tkn.SignedString([]byte(secretKey)) + if err != nil { + panic(err) + } + validToken = "Bearer " + validToken + token := oauth2.Token{ TokenType: "Bearer", + AccessToken: "asdf", RefreshToken: "abcdef", Expiry: time.Now().Add(time.Hour), } - expiredToken := oauth2.Token{ - TokenType: "Bearer", - RefreshToken: "abcdef", - Expiry: time.Now(), + // make an expired token + expiredTkn := jwt.New(jwt.SigningMethodHS256) + expiredClaims := expiredTkn.Claims.(jwt.MapClaims) + expiredClaims["molecula-idp-groups"] = groupString + expiredClaims["oid"] = "42" + expiredClaims["name"] = "todd" + expiredClaims["exp"] = "1" + expiredToken, err := expiredTkn.SignedString([]byte(secretKey)) + if err != nil { + panic(err) } - - grp := authn.Group{ - UserID: "snowstorm", - GroupID: "abcd123-A", - GroupName: "Romantic Painters", - } - - validCV := authn.AuthContext{ - UserID: "snowstorm", - UserName: "J.M.W. Turner", - GroupMembership: []authn.Group{grp}, - Token: &token, - } - - emptyCV := authn.AuthContext{ - UserID: "narcissus", - UserName: "Caravaggio", - GroupMembership: []authn.Group{}, - Token: &token, - } - expiredCV := authn.AuthContext{ - UserID: "narcissus", - UserName: "Caravaggio", - GroupMembership: []authn.Group{grp}, - Token: &expiredToken, - } - - secure := securecookie.New(hashKey, blockKey) - validEncodedCV, _ := secure.Encode("molecula-chip", validCV) - noGroupEncodedCV, _ := secure.Encode("molecula-chip", emptyCV) - expiredEncodedCV, _ := secure.Encode("molecula-chip", expiredCV) + expiredToken = "Bearer " + expiredToken validCookie := &gohttp.Cookie{ Name: "molecula-chip", - Value: validEncodedCV, - Path: "/", - Secure: true, - HttpOnly: true, - Expires: token.Expiry, - } - noGroupCookie := &gohttp.Cookie{ - Name: "molecula-chip", - Value: noGroupEncodedCV, - Path: "/", - Secure: true, - HttpOnly: true, - Expires: token.Expiry, - } - expiredCookie := &gohttp.Cookie{ - Name: "molecula-chip", - Value: expiredEncodedCV, - Path: "/", - Secure: true, - HttpOnly: true, - Expires: time.Now().Add(time.Minute * -1), - } - emptyCookie := &gohttp.Cookie{ - Name: "molecula-chip", - Value: "", - Path: "/", - Secure: true, - HttpOnly: true, - Expires: token.Expiry, - } - unEncodedCookie := &gohttp.Cookie{ - Name: "molecula-chip", - Value: "The quick brown fox", + Value: token.AccessToken, Path: "/", Secure: true, HttpOnly: true, @@ -321,7 +277,9 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` name string path string kind string + method string yamlData string + token string cookie *gohttp.Cookie handler endpoint fn evaluate @@ -331,7 +289,7 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` path: "/login", kind: "type1", cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleLogin(w, r) }, + handler: h.handleLogin, fn: func(w *httptest.ResponseRecorder, data []byte) { if strings.Index(string(data), AuthorizeURL) != 9 { t.Errorf("incorrect redirect url: expected: %s, got: %s", AuthorizeURL, string(data)) @@ -343,7 +301,7 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` path: "/logout", kind: "type1", cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleLogout(w, r) }, + handler: h.handleLogout, fn: func(w *httptest.ResponseRecorder, data []byte) { if w.Result().Cookies()[0].Value != "" { t.Errorf("expected cookie to be cleared, got: %+v", w.Result().Cookies()[0].Value) @@ -351,82 +309,69 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` }, }, { - name: "Authenticate-Groups", + name: "Authenticate-ValidToken", path: "/auth", - kind: "type1", - cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleCheckAuthentication(w, r) }, + kind: "bearer", + token: validToken, + handler: h.handleCheckAuthentication, fn: func(w *httptest.ResponseRecorder, data []byte) { if w.Result().StatusCode != 200 { - t.Errorf("expected http code 200, got: %+v", w.Result().StatusCode) + body, _ := readResponse(w) + t.Errorf("expected http code 200, got: %+v with body: %+v", w.Result().StatusCode, body) } }, }, { - name: "Authenticate-NoGroups", + name: "Authenticate-NoToken", path: "/auth", kind: "type1", - cookie: noGroupCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleCheckAuthentication(w, r) }, + handler: h.handleCheckAuthentication, fn: func(w *httptest.ResponseRecorder, data []byte) { - // status forbidden - if w.Result().StatusCode != 403 { + // not token at all == status forbidden + if w.Result().StatusCode != 401 { + t.Errorf("expected http code 401, got: %+v", w.Result().StatusCode) + } + }, + }, + { + name: "Authenticate-InvalidToken", + path: "/auth", + kind: "type1", + token: "this isn't a real token", + handler: h.handleCheckAuthentication, + fn: func(w *httptest.ResponseRecorder, data []byte) { + // no valid token in header == Unauthorized + if w.Result().StatusCode != gohttp.StatusUnauthorized { + t.Errorf("expected http code 401, got: %+v", w.Result().StatusCode) + } + }, + }, + { + name: "Authenticate-ExpiredToken", + path: "/auth", + kind: "type1", + token: expiredToken, + handler: h.handleCheckAuthentication, + fn: func(w *httptest.ResponseRecorder, data []byte) { + // expired token == unauthorized + if w.Result().StatusCode != 401 { t.Errorf("expected http code 403, got: %+v", w.Result().StatusCode) } }, }, - { - name: "Authenticate-MalformedCookie", - path: "/auth", - kind: "type1", - cookie: unEncodedCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleCheckAuthentication(w, r) }, - fn: func(w *httptest.ResponseRecorder, data []byte) { - // redirect to signin - if w.Result().StatusCode != 307 { - t.Errorf("expected http code 307, got: %+v", w.Result().StatusCode) - } - }, - }, - { - name: "Authenticate-Expired", - path: "/auth", - kind: "type1", - cookie: expiredCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleCheckAuthentication(w, r) }, - fn: func(w *httptest.ResponseRecorder, data []byte) { - // redirect to signin - if w.Result().StatusCode != 307 { - t.Errorf("expected http code 307, got: %+v", w.Result().StatusCode) - } - }, - }, - { - name: "Authenticate-NoCookie", - path: "/auth", - kind: "type1", - cookie: emptyCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleCheckAuthentication(w, r) }, - fn: func(w *httptest.ResponseRecorder, data []byte) { - // redirect to signin - if w.Result().StatusCode != 307 { - t.Errorf("expected http code 307, got: %+v", w.Result().StatusCode) - } - }, - }, { name: "UserInfo", path: "/userinfo", - kind: "type1", - cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleUserInfo(w, r) }, + kind: "bearer", + token: validToken, + handler: h.handleUserInfo, fn: func(w *httptest.ResponseRecorder, data []byte) { uinfo := authn.UserInfo{} err = json.Unmarshal(data, &uinfo) if err != nil { t.Errorf("unmarshalling userinfo") } - if uinfo.UserID != "snowstorm" && uinfo.UserName != "J.M.W. Turner" { + if uinfo.UserID != "42" && uinfo.UserName != "todd" { t.Errorf("expected http code 400, got: %+v", uinfo) } }, @@ -434,27 +379,21 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` { name: "UserInfo-NoCookie", path: "/userinfo", - kind: "type1", - cookie: emptyCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleUserInfo(w, r) }, + kind: "bearer", + token: "", + handler: h.handleUserInfo, fn: func(w *httptest.ResponseRecorder, data []byte) { - uinfo := authn.UserInfo{} - err = json.Unmarshal(data, &uinfo) - if err != nil { - t.Errorf("unmarshalling userinfo") - } - if uinfo.UserID != "" && uinfo.UserName != "" { - t.Errorf("expected http code 400, got: %+v", uinfo) + if got := w.Result().StatusCode; got != gohttp.StatusForbidden { + t.Errorf("expected 403, got %v", got) } }, }, - { name: "Redirect-NoAuthCode", path: "/redirect", kind: "type1", cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleRedirect(w, r) }, + handler: h.handleRedirect, fn: func(w *httptest.ResponseRecorder, data []byte) { if strings.Index(string(data), AuthorizeURL) != 9 { if w.Result().StatusCode != 400 { @@ -468,7 +407,7 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` path: "/redirect", kind: "type2", cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleRedirect(w, r) }, + handler: h.handleRedirect, fn: func(w *httptest.ResponseRecorder, data []byte) { if strings.Index(string(data), AuthorizeURL) != 9 { if w.Result().StatusCode != 400 { @@ -482,7 +421,7 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` path: "/login", kind: "type1", cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleLogin(w, r) }, + handler: hOff.handleLogin, fn: func(w *httptest.ResponseRecorder, data []byte) { if strings.Index(string(data), AuthorizeURL) != 9 { if w.Result().StatusCode != 204 { @@ -496,7 +435,7 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` path: "/logout", kind: "type1", cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleLogout(w, r) }, + handler: hOff.handleLogout, fn: func(w *httptest.ResponseRecorder, data []byte) { if strings.Index(string(data), AuthorizeURL) != 9 { if w.Result().StatusCode != 204 { @@ -510,7 +449,7 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` path: "/userinfo", kind: "type1", cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleUserInfo(w, r) }, + handler: hOff.handleUserInfo, fn: func(w *httptest.ResponseRecorder, data []byte) { if strings.Index(string(data), AuthorizeURL) != 9 { if w.Result().StatusCode != 204 { @@ -524,7 +463,7 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` path: "/auth", kind: "type1", cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleCheckAuthentication(w, r) }, + handler: hOff.handleCheckAuthentication, fn: func(w *httptest.ResponseRecorder, data []byte) { if strings.Index(string(data), AuthorizeURL) != 9 { if w.Result().StatusCode != 204 { @@ -538,7 +477,7 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` path: "/redirect", kind: "type1", cookie: validCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleRedirect(w, r) }, + handler: hOff.handleRedirect, fn: func(w *httptest.ResponseRecorder, data []byte) { if strings.Index(string(data), AuthorizeURL) != 9 { if w.Result().StatusCode != 204 { @@ -563,59 +502,57 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` }, }, { - name: "MW-ExpiredAuth", - path: "/index/{index}/query", - kind: "middleware", - cookie: expiredCookie, + // this tests that there are no permissions read in even though + // auth is turned on, so we get a 500 + name: "MW-CreateIndexGood", + path: "/index/abcd", + kind: "bearer", + method: gohttp.MethodPost, + token: validToken, handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { - f := h.chkAuthN(h.handlePostQuery) + h := h + var p authz.GroupPermissions + if err := p.ReadPermissionsFile(strings.NewReader(permissions1)); err != nil { + t.Errorf("Error: %s", err) + } + h.permissions = &p + + f := h.chkAuthZ(h.handlePostIndex, authz.Admin) f(w, r) }, fn: func(w *httptest.ResponseRecorder, data []byte) { - if w.Result().StatusCode != 307 { - t.Errorf("expected http code 307, got: %+v", w.Result().StatusCode) + if got, want := w.Result().StatusCode, gohttp.StatusForbidden; got != want { + t.Errorf("expected %v, got %v", want, got) } - }, }, { - name: "MW-ExpiredAuth2", - path: "/index/{index}/query", - kind: "middleware", - cookie: expiredCookie, - handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { - f := h.chkAuthZ(h.handlePostQuery, authz.Admin) - f(w, r) - }, - fn: func(w *httptest.ResponseRecorder, data []byte) { - if w.Result().StatusCode != 307 { - t.Errorf("expected http code 307, got: %+v", w.Result().StatusCode) - } - - }, - }, - { - name: "MW-NoPermissions", - path: "/index/{index}/query", - kind: "middleware", - cookie: validCookie, + // this tests that there are no permissions read in even though + // auth is turned on, so we get a 500 + name: "MW-NoPermissions", + path: "/index/{index}/query", + kind: "bearer", + token: validToken, handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h := h f := h.chkAuthZ(h.handlePostQuery, authz.Write) - assert.Panics(t, func() { f(w, r) }, "expected panic") + f(w, r) + }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + if got, want := w.Result().StatusCode, gohttp.StatusInternalServerError; got != want { + t.Errorf("expected %v, got %v", want, got) + } }, - fn: func(w *httptest.ResponseRecorder, data []byte) {}, }, { - name: "MW-NoIndexNoAdmin", - path: "/index/{index}/query", - kind: "middleware", - cookie: validCookie, + name: "MW-NoQuery", + path: "/index/{index}/query", + kind: "bearer", + token: validToken, handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h := h - permFile := strings.NewReader(permissions1) var p authz.GroupPermissions - if err := p.ReadPermissionsFile(permFile); err != nil { + if err := p.ReadPermissionsFile(strings.NewReader(permissions1)); err != nil { t.Errorf("Error: %s", err) } h.permissions = &p @@ -623,17 +560,16 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` f(w, r) }, fn: func(w *httptest.ResponseRecorder, data []byte) { - if w.Result().StatusCode != 400 { - t.Errorf("expected http code 400, got: %+v", w.Result().StatusCode) + if got, want := w.Result().StatusCode, gohttp.StatusBadRequest; got != want { + t.Errorf("expected %v, got: %+v", want, got) } - }, }, } for _, test := range tests { switch test.kind { - case "type1": + case "type1", "middleware": t.Run(test.name, func(t *testing.T) { r := httptest.NewRequest(gohttp.MethodGet, test.path, nil) w := httptest.NewRecorder() @@ -664,20 +600,21 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` test.fn(w, data) }) - case "middleware": + case "bearer": t.Run(test.name, func(t *testing.T) { - r := httptest.NewRequest(gohttp.MethodGet, test.path, nil) - w := httptest.NewRecorder() - if test.cookie != nil { - r.AddCookie(test.cookie) + if test.method == "" { + test.method = gohttp.MethodGet + } + r := httptest.NewRequest(test.method, test.path, nil) + w := httptest.NewRecorder() + if test.token != "" { + r.Header.Add("Authorization", test.token) } - test.handler(w, r) data, err := readResponse(w) if err != nil { t.Errorf("expected no errors reading response, got: %+v", err) } - test.fn(w, data) }) } diff --git a/install/featurebase.conf b/install/featurebase.conf index 94903194c..3389ff90d 100644 --- a/install/featurebase.conf +++ b/install/featurebase.conf @@ -373,7 +373,7 @@ log-path = "/var/log/molecula/featurebase.log" # ============================================================================== # Enable/Disable AuthN/AuthZ for featurebase # Can choose identity provider, defaults for Azure Active Directory -# Use provided keygen binary to generate hash and block keys with sufficient length and entropy +# Use provided keygen binary to generate a secret key with sufficient length and entropy # [auth] # enable = false # client-id = "" @@ -383,7 +383,6 @@ log-path = "/var/log/molecula/featurebase.log" # group-endpoint-url = "" # logout-url = "" # scopes = ["", ""] -# hash-key = "" -# block-key = "" +# secret-key = "" # permissions = "" # query-log-path = "" diff --git a/lattice/src/services/useAuth.test.tsx b/lattice/src/services/useAuth.test.tsx index 2cf784786..f16a05b2f 100644 --- a/lattice/src/services/useAuth.test.tsx +++ b/lattice/src/services/useAuth.test.tsx @@ -51,7 +51,7 @@ test('test useAuth - expect authenticated', async () => { test('test useAuth - expect not authed', async () => { const mockResponse: AxiosResponse = { - status: 200, + status: 401, data: '', statusText: '', headers: {}, diff --git a/lattice/src/services/useAuth.tsx b/lattice/src/services/useAuth.tsx index 0c94cb733..a30154537 100644 --- a/lattice/src/services/useAuth.tsx +++ b/lattice/src/services/useAuth.tsx @@ -54,7 +54,7 @@ function useProvideAuth() { // Turn on Authentication setIsAuthOn(true); - if (res.data === 'OK') { + if (res.status === 200) { // User is authenticated setIsAuthenticated(true); diff --git a/server/config.go b/server/config.go index 8fb7260f0..e3fe21fe3 100644 --- a/server/config.go +++ b/server/config.go @@ -242,8 +242,7 @@ type Auth struct { GroupEndpointURL string `toml:"group-endpoint-url"` LogoutURL string `toml:"logout-url"` Scopes []string `toml:"scopes"` - HashKey string `toml:"hash-key"` - BlockKey string `toml:"block-key"` + SecretKey string `toml:"secret-key"` PermissionsFile string `toml:"permissions"` QueryLogPath string `toml:"query-log-path"` } @@ -614,24 +613,29 @@ func (c *Config) ValidateAuth() (errors []error) { if !c.Auth.Enable { return } - authConfig := map[string]string{ - "ClientId": c.Auth.ClientId, - "ClientSecret": c.Auth.ClientSecret, - "AuthorizeURL": c.Auth.AuthorizeURL, - "TokenURL": c.Auth.TokenURL, - "GroupEndpointURL": c.Auth.GroupEndpointURL, - "LogoutURL": c.Auth.LogoutURL, - "HashKey": c.Auth.HashKey, - "BlockKey": c.Auth.BlockKey, + authConfig := []struct { + name string + val string + }{ + {name: "ClientId", val: c.Auth.ClientId}, + {name: "ClientSecret", val: c.Auth.ClientSecret}, + {name: "AuthorizeURL", val: c.Auth.AuthorizeURL}, + {name: "TokenURL", val: c.Auth.TokenURL}, + {name: "GroupEndpointURL", val: c.Auth.GroupEndpointURL}, + {name: "LogoutURL", val: c.Auth.LogoutURL}, + {name: "SecretKey", val: c.Auth.SecretKey}, + {name: "QueryLogPath", val: c.Auth.QueryLogPath}, } - for name, value := range authConfig { + for _, configOpt := range authConfig { + name := configOpt.name + value := configOpt.val if value == "" { errors = append(errors, fmt.Errorf("empty string for auth config %s", name)) continue } - if name == "HashKey" || name == "BlockKey" { + if name == "SecretKey" { if len(value) != 64 { errors = append(errors, fmt.Errorf("invalid key length for %s. exp %d, got %d", name, 64, len(value))) } diff --git a/server/config_internal_test.go b/server/config_internal_test.go index 4af9007fe..0dcf3ffb4 100644 --- a/server/config_internal_test.go +++ b/server/config_internal_test.go @@ -319,15 +319,14 @@ func TestConfig_validateAuth(t *testing.T) { GroupEndpointURL: emptyString, LogoutURL: emptyString, Scopes: validStringSlice, - HashKey: emptyString, - BlockKey: emptyString, + SecretKey: emptyString, }, }, { // Auth enabled, keys are invalid length []string{ errorMesgKey, - errorMesgKey, + errorMesgEmpty, }, Auth{ Enable: enable, @@ -338,8 +337,7 @@ func TestConfig_validateAuth(t *testing.T) { GroupEndpointURL: validTestURL, LogoutURL: validTestURL, Scopes: validStringSlice, - HashKey: validString, - BlockKey: validString, + SecretKey: validString, }, }, { @@ -358,8 +356,8 @@ func TestConfig_validateAuth(t *testing.T) { GroupEndpointURL: invalidURL, LogoutURL: invalidURL, Scopes: validStringSlice, - HashKey: validKey, - BlockKey: validKey, + SecretKey: validKey, + QueryLogPath: "thisnisfasdfPAth", }, }, { @@ -376,8 +374,8 @@ func TestConfig_validateAuth(t *testing.T) { GroupEndpointURL: validTestURL, LogoutURL: validTestURL, Scopes: emptySlice, - HashKey: validKey, - BlockKey: validKey, + SecretKey: validKey, + QueryLogPath: "thisaisdf aPath", }, }, { @@ -392,8 +390,8 @@ func TestConfig_validateAuth(t *testing.T) { GroupEndpointURL: validTestURL, LogoutURL: validTestURL, Scopes: validStringSlice, - HashKey: validKey, - BlockKey: validKey, + SecretKey: validKey, + QueryLogPath: "thisIsAPAth", }, }, { @@ -408,8 +406,7 @@ func TestConfig_validateAuth(t *testing.T) { GroupEndpointURL: invalidURL, LogoutURL: validTestURL, Scopes: validStringSlice, - HashKey: validKey, - BlockKey: emptyString, + SecretKey: emptyString, }, }, } diff --git a/server/grpc.go b/server/grpc.go index 0dc6e909a..3683de99a 100644 --- a/server/grpc.go +++ b/server/grpc.go @@ -13,10 +13,14 @@ import ( "time" "github.com/improbable-eng/grpc-web/go/grpcweb" - "github.com/molecula/featurebase/v2" + pilosa "github.com/molecula/featurebase/v2" + "github.com/molecula/featurebase/v2/authn" + "github.com/molecula/featurebase/v2/authz" "github.com/molecula/featurebase/v2/logger" + "github.com/molecula/featurebase/v2/pql" pb "github.com/molecula/featurebase/v2/proto" vdsm_pb "github.com/molecula/featurebase/v2/proto/vdsm" + "github.com/molecula/featurebase/v2/sql" "github.com/molecula/featurebase/v2/stats" "github.com/pkg/errors" "google.golang.org/grpc" @@ -30,6 +34,7 @@ import ( // GRPCHandler contains methods which handle the various gRPC requests. type GRPCHandler struct { api *pilosa.API + perms *authz.GroupPermissions logger logger.Logger stats stats.StatsClient inspectDeprecated sync.Once @@ -49,6 +54,11 @@ func (h *GRPCHandler) WithStats(stats stats.StatsClient) *GRPCHandler { return h } +func (h *GRPCHandler) WithPerms(perms *authz.GroupPermissions) *GRPCHandler { + h.perms = perms + return h +} + // errorToStatusError appends an appropriate grpc status code // to the error (returning it as a status.Error). func errToStatusError(err error) error { @@ -126,15 +136,49 @@ func (h *GRPCHandler) execSQL(ctx context.Context, queryStr string) (pb.ToRowser return execSQL(ctx, h.api, h.logger, queryStr) } +func isAllowed(requested []string, allowed []string) bool { + if len(allowed) == 0 { + return false + } + + for _, r := range requested { + in := false + for _, a := range allowed { + if a == r { + in = true + } + } + if !in { + return false + } + } + return true +} + // QuerySQL handles the SQL request and sends RowResponses to the stream. func (h *GRPCHandler) QuerySQL(req *pb.QuerySQLRequest, stream pb.Pilosa_QuerySQLServer) error { + ctx := stream.Context() + uinfo := ctx.Value("userinfo") + if uinfo != nil { + // authz + m := sql.NewMapper() + parsed, err := m.MapSQL(req.Sql) + if err != nil { + return errors.Wrap(err, "parsing SQL") + } + if !h.perms.IsAdmin(uinfo.(*authn.UserInfo).Groups) { + if !isAllowed(parsed.Tables, h.perms.GetAuthorizedIndexList(uinfo.(*authn.UserInfo).Groups, authz.Read)) { + return status.Error(codes.PermissionDenied, "insufficient permissions to access requested tables") + } + } + } + start := time.Now() results, err := h.execSQL(stream.Context(), req.Sql) duration := time.Since(start) if err != nil { return err } - err = stream.SendHeader(metadata.New(map[string]string{ "duration": strconv.Itoa(int(duration)), })) @@ -198,6 +242,23 @@ func (h *GRPCHandler) QueryPQL(req *pb.QueryPQLRequest, stream pb.Pilosa_QueryPQ Query: req.Pql, } + ctx := stream.Context() + uinfo := ctx.Value("userinfo") + if uinfo != nil { + lperm := authz.Read + q, err := pql.ParseString(req.Pql) + if err != nil { + return status.Error(codes.InvalidArgument, err.Error()) + } + if q.WriteCallN() > 0 { + lperm = authz.Write + } + if !h.perms.IsAdmin(uinfo.(*authn.UserInfo).Groups) { + if !isAllowed([]string{req.Index}, h.perms.GetAuthorizedIndexList(uinfo.(*authn.UserInfo).Groups, lperm)) { + return status.Error(codes.PermissionDenied, "insufficient permissions to access requested indexes") + } + } + } t := time.Now() resp, err := h.api.Query(stream.Context(), &query) durQuery := time.Since(t) @@ -246,6 +307,22 @@ func (h *GRPCHandler) QueryPQLUnary(ctx context.Context, req *pb.QueryPQLRequest Index: req.Index, Query: req.Pql, } + uinfo := ctx.Value("userinfo") + if uinfo != nil { + lperm := authz.Read + q, err := pql.ParseString(req.Pql) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if q.WriteCallN() > 0 { + lperm = authz.Write + } + if !h.perms.IsAdmin(uinfo.(*authn.UserInfo).Groups) { + if !isAllowed([]string{req.Index}, h.perms.GetAuthorizedIndexList(uinfo.(*authn.UserInfo).Groups, lperm)) { + return nil, status.Error(codes.PermissionDenied, fmt.Sprintf("insufficient permissions for %v", req.Index)) + } + } + } t := time.Now() resp, err := h.api.Query(ctx, &query) @@ -291,6 +368,12 @@ func (h *GRPCHandler) QueryPQLUnary(ctx context.Context, req *pb.QueryPQLRequest // CreateIndex creates a new Index func (h *GRPCHandler) CreateIndex(ctx context.Context, req *pb.CreateIndexRequest) (*pb.CreateIndexResponse, error) { + uinfo := ctx.Value("userinfo") + if uinfo != nil { + if !h.perms.IsAdmin(uinfo.(*authn.UserInfo).Groups) { + return nil, status.Error(codes.PermissionDenied, "must be admin to create index") + } + } // Always enable TrackExistence for gRPC-created indexes opts := pilosa.IndexOptions{Keys: req.Keys, TrackExistence: true} _, err := h.api.CreateIndex(ctx, req.Name, opts) @@ -302,6 +385,20 @@ func (h *GRPCHandler) CreateIndex(ctx context.Context, req *pb.CreateIndexReques // GetIndex returns a single Index given a name func (h *GRPCHandler) GetIndex(ctx context.Context, req *pb.GetIndexRequest) (*pb.GetIndexResponse, error) { + uinfo := ctx.Value("userinfo") + if uinfo != nil { + pp, ok := uinfo.(*authn.UserInfo) + if !ok { + return nil, status.Error(codes.InvalidArgument, "malformed auth header") + } + p, err := h.perms.GetPermissions(pp, req.Name) + if err != nil { + return nil, err + } + if !p.Satisfies(authz.Read) { + return nil, status.Error(codes.PermissionDenied, fmt.Sprintf("permission denied for index %v", req.Name)) + } + } schema, err := h.api.Schema(ctx, false) if err != nil { return nil, errToStatusError(err) @@ -317,20 +414,44 @@ func (h *GRPCHandler) GetIndex(ctx context.Context, req *pb.GetIndexRequest) (*p // GetIndexes returns a list of all Indexes func (h *GRPCHandler) GetIndexes(ctx context.Context, req *pb.GetIndexesRequest) (*pb.GetIndexesResponse, error) { + uinfo := ctx.Value("userinfo") + var pp *authn.UserInfo + if uinfo != nil { + var ok bool + pp, ok = uinfo.(*authn.UserInfo) + if !ok { + return nil, status.Error(codes.InvalidArgument, "malformed auth header") + } + } schema, err := h.api.Schema(ctx, false) if err != nil { return nil, errToStatusError(err) } indexes := make([]*pb.Index, len(schema)) - for i, index := range schema { - indexes[i] = &pb.Index{Name: index.Name} + i := 0 + for _, index := range schema { + if pp != nil { + if p, err := h.perms.GetPermissions(pp, index.Name); err == nil && p.Satisfies(authz.Read) { + indexes[i] = &pb.Index{Name: index.Name} + i += 1 + } + } else { + indexes[i] = &pb.Index{Name: index.Name} + i += 1 + } } return &pb.GetIndexesResponse{Indexes: indexes}, nil } // DeleteIndex deletes an Index func (h *GRPCHandler) DeleteIndex(ctx context.Context, req *pb.DeleteIndexRequest) (*pb.DeleteIndexResponse, error) { + uinfo := ctx.Value("userinfo") + if uinfo != nil { + if !h.perms.IsAdmin(uinfo.(*authn.UserInfo).Groups) { + return nil, status.Error(codes.PermissionDenied, "must be admin to delete index") + } + } err := h.api.DeleteIndex(ctx, req.Name) if err != nil { return nil, errToStatusError(err) @@ -1301,6 +1422,8 @@ type grpcServer struct { grpcServer *grpc.Server ln net.Listener tlsConfig *tls.Config + auth *authn.Auth + perms *authz.GroupPermissions logger logger.Logger stats stats.StatsClient @@ -1343,6 +1466,20 @@ func OptGRPCServerStats(stats stats.StatsClient) grpcServerOption { } } +func OptGRPCServerAuth(authn *authn.Auth) grpcServerOption { + return func(s *grpcServer) error { + s.auth = authn + return nil + } +} + +func OptGRPCServerPerm(gp *authz.GroupPermissions) grpcServerOption { + return func(s *grpcServer) error { + s.perms = gp + return nil + } +} + func (s *grpcServer) Serve() error { s.logger.Infof("enabled grpc listening on %s", s.ln.Addr()) @@ -1398,10 +1535,37 @@ func NewGRPCServer(opts ...grpcServerOption) (*grpcServer, error) { creds := credentials.NewTLS(server.tlsConfig) gopts = append(gopts, grpc.Creds(creds)) } + //if auth enabled + if server.auth != nil { + gopts = append(gopts, grpc.UnaryInterceptor( + func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + ctx, err := Valid(ctx, server.auth) + if err != nil { + return nil, err + } + return handler(ctx, req) + }, + )) + gopts = append(gopts, grpc.StreamInterceptor( + func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + ctx, err := Valid(ss.Context(), server.auth) + if err != nil { + return err + } + return handler(srv, newWrappedStream(ss, ctx)) + }, + )) + } // create grpc server server.grpcServer = grpc.NewServer(gopts...) grpcHandler := NewGRPCHandler(server.api).WithLogger(server.logger).WithStats(server.stats) + + // add server permissions if we've got 'em + if server.perms != nil { + grpcHandler.perms = server.perms + } + pb.RegisterPilosaServer(server.grpcServer, grpcHandler) vdsm_pb.RegisterMoleculaServer(server.grpcServer, NewVDSMGRPCHandler(grpcHandler, server.api).WithLogger(server.logger).WithStats(server.stats)) @@ -1410,3 +1574,58 @@ func NewGRPCServer(opts ...grpcServerOption) (*grpcServer, error) { return server, nil } + +// wrappedStream wraps around the embedded grpc.ServerStream, and intercepts the RecvMsg and +// SendMsg method call. +type wrappedStream struct { + grpc.ServerStream + uiContext context.Context +} + +func (w *wrappedStream) Context() context.Context { + return w.uiContext +} +func (w *wrappedStream) RecvMsg(m interface{}) error { + return w.ServerStream.RecvMsg(m) +} + +func (w *wrappedStream) SendMsg(m interface{}) error { + return w.ServerStream.SendMsg(m) +} + +func newWrappedStream(s grpc.ServerStream, ctx context.Context) grpc.ServerStream { + return &wrappedStream{s, ctx} +} + +func Valid(ctx context.Context, auth *authn.Auth) (context.Context, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return ctx, status.Errorf(codes.InvalidArgument, "missing metadata") + } + authorization, ok := md["authorization"] + + if !ok { + c, ok := md["cookie"] + if !ok { + return ctx, status.Errorf(codes.InvalidArgument, "missing authorization token") + } + cookies := strings.Split(c[0], "; ") + for _, cookie := range cookies { + if strings.HasPrefix(cookie, "molecula-chip") { + authorization = strings.Split(cookie, "molecula-chip=")[1:] + } + } + if len(authorization) == 0 { + return ctx, status.Errorf(codes.InvalidArgument, "missing authorization token") + + } + } + + token := strings.TrimPrefix(authorization[0], "Bearer ") + userinfo, err := auth.Authenticate(token) + if err != nil { + return ctx, status.Errorf(codes.Unauthenticated, err.Error()) + } + + return context.WithValue(ctx, "userinfo", userinfo), nil +} diff --git a/server/grpc_test.go b/server/grpc_test.go index b7f8bb465..63482f687 100644 --- a/server/grpc_test.go +++ b/server/grpc_test.go @@ -3,13 +3,21 @@ package server_test import ( "context" + "encoding/hex" "fmt" + "io" + "os" + "path/filepath" "reflect" "strconv" "strings" "testing" + "time" - "github.com/molecula/featurebase/v2" + "github.com/golang-jwt/jwt" + pilosa "github.com/molecula/featurebase/v2" + "github.com/molecula/featurebase/v2/authn" + "github.com/molecula/featurebase/v2/authz" "github.com/molecula/featurebase/v2/pql" pb "github.com/molecula/featurebase/v2/proto" "github.com/molecula/featurebase/v2/server" @@ -385,7 +393,7 @@ func TestQueryPQL(t *testing.T) { m.MustCreateField(t, i.Name(), "f", pilosa.OptFieldKeys()) gh := server.NewGRPCHandler(m.API) - mock := &mockPilosa_QuerySQLServer{} + mock := &mockPilosa_QuerySQLServer{ctx: context.Background()} err := gh.QueryPQL(&pb.QueryPQLRequest{ Index: i.Name(), @@ -941,7 +949,7 @@ func TestQuerySQL(t *testing.T) { if strings.HasPrefix(test.sql, "drop table") { t.Skip("drop statements can only run once") } - mock := &mockPilosa_QuerySQLServer{} + mock := &mockPilosa_QuerySQLServer{ctx: context.Background()} err := gh.QuerySQL(&pb.QuerySQLRequest{Sql: test.sql}, mock) if err != nil { t.Fatalf("sql: %s, error: %v", test.sql, err) @@ -968,7 +976,6 @@ func TestQuerySQLUnaryWithError(t *testing.T) { ctx := grpc.NewContextWithServerTransportStream(context.Background(), stream) gh, tearDownFunc := setUpTestQuerySQLUnary(ctx, t) defer tearDownFunc() - tests := []struct { sql string err error @@ -1005,6 +1012,157 @@ func TestQuerySQLUnaryWithError(t *testing.T) { } }) } + permissions := ` +"user-groups": + "dca35310-ecda-4f23-86cd-876aee55906b": + "grouper": "read" + "dca35310-ecda-4f23-86cd-876aee55906f": + "grouper": "write" +admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` + + permFile := writeTestFile(t, "permissions.yaml", permissions) + auth := server.Auth{ + Enable: true, + ClientId: "e9088663-eb08-41d7-8f65-efb5f54bbb71", + ClientSecret: "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + AuthorizeURL: "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/token", + GroupEndpointURL: "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true", + LogoutURL: "https://login.microsoftonline.com/common/oauth2/v2.0/logout", + Scopes: []string{"https://graph.microsoft.com/.default", "offline_access"}, + SecretKey: "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + PermissionsFile: permFile, + } + var p authz.GroupPermissions + permsFile, err := os.Open(permFile) + if err != nil { + t.Fatal(err) + } + defer permsFile.Close() + + if err = p.ReadPermissionsFile(permsFile); err != nil { + t.Fatal(err) + } + gh = gh.WithPerms(&p) + makeUser := func(groups []authn.Group, name string) *authn.UserInfo { + // make a valid token + tkn := jwt.New(jwt.SigningMethodHS256) + claims := tkn.Claims.(jwt.MapClaims) + groupString, _ := authn.ToGob64(groups) + claims["molecula-idp-groups"] = groupString + claims["oid"] = "42" + claims["name"] = name + secretKey, _ := hex.DecodeString(auth.SecretKey) + + validToken, err := tkn.SignedString(secretKey) + if err != nil { + panic(err) + } + validToken = "Bearer " + validToken + + adminUser := &authn.UserInfo{ + UserID: "fake" + name, + UserName: name, + Groups: groups, + Token: validToken, + Expiry: time.Time{}, + } + return adminUser + } + + user := makeUser([]authn.Group{{GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "adminGroup"}}, "admin") + adminCtx := context.WithValue( + ctx, + "userinfo", + user, + ) + readuser := makeUser([]authn.Group{{GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: "readers"}}, "admin") + readCtx := context.WithValue( + ctx, + "userinfo", + readuser, + ) + writeuser := makeUser([]authn.Group{{GroupID: "dca35310-ecda-4f23-86cd-876aee55906f", GroupName: "writers"}}, "admin") + writeCtx := context.WithValue( + ctx, + "userinfo", + writeuser, + ) + + sql := "select * from grouper" + t.Run("test-auth-with-admin-sqlUnary", func(t *testing.T) { + _, err := gh.QuerySQLUnary(adminCtx, &pb.QuerySQLRequest{Sql: sql}) + if err != nil { + t.Fatal(err) + } + }) + t.Run("test-auth-with-read-sqlUnary", func(t *testing.T) { + _, err := gh.QuerySQLUnary(readCtx, &pb.QuerySQLRequest{Sql: sql}) + if err != nil { + t.Fatal(err) + } + }) + t.Run("test-admin-auth-sql", func(t *testing.T) { + mock := &mockPilosa_QuerySQLServer{ctx: adminCtx} + + err := gh.QuerySQL(&pb.QuerySQLRequest{Sql: sql}, mock) + if err != nil { + t.Fatal(err) + } + }) + t.Run("test-admin-auth-get-index", func(t *testing.T) { + _, err := gh.GetIndex(adminCtx, &pb.GetIndexRequest{Name: "grouper"}) + if err != nil { + t.Fatal(err) + } + }) + t.Run("test-admin-auth-get-indexes", func(t *testing.T) { + + _, err := gh.GetIndexes(adminCtx, &pb.GetIndexesRequest{}) + if err != nil { + t.Fatal(err) + } + }) + t.Run("test-admin-auth-pql", func(t *testing.T) { + _, err := gh.QueryPQLUnary(adminCtx, &pb.QueryPQLRequest{ + Index: "grouper", + Pql: `Set(0, color="red")`, + }) + if err != nil { + // Unary query should work + t.Fatal(err) + } + }) + t.Run("test-write-with-read-auth-pql", func(t *testing.T) { + _, err := gh.QueryPQLUnary(readCtx, &pb.QueryPQLRequest{ + Index: "grouper", + Pql: `Set(0, color="red")`, + }) + if err == nil { + //should not be able to write + t.Fatal(err) + } + }) + t.Run("test-write-with-write-auth-pql", func(t *testing.T) { + _, err := gh.QueryPQLUnary(writeCtx, &pb.QueryPQLRequest{ + Index: "grouper", + Pql: `Set(0, color="red")`, + }) + if err != nil { + //should be able to write + t.Fatal(err) + } + }) + t.Run("test-write-with-admin-auth-pql", func(t *testing.T) { + _, err := gh.QueryPQLUnary(adminCtx, &pb.QueryPQLRequest{ + Index: "grouper", + Pql: `Set(0, color="green")`, + }) + if err != nil { + //should be able to write + t.Fatal(err) + } + }) } func TestCRUDIndexes(t *testing.T) { @@ -1014,7 +1172,6 @@ func TestCRUDIndexes(t *testing.T) { stream := &MockServerTransportStream{} ctx := grpc.NewContextWithServerTransportStream(context.Background(), stream) gh := server.NewGRPCHandler(m.API) - t.Run("CreateIndex", func(t *testing.T) { // Try CreateIndex for testindex1 _, err := gh.CreateIndex(ctx, &pb.CreateIndexRequest{Name: "testindex1", Keys: true}) @@ -1448,6 +1605,7 @@ func (stream *MockServerTransportStream) ClearMD() { type mockPilosa_QuerySQLServer struct { MockServerTransportStream + ctx context.Context pb.Pilosa_QuerySQLServer Results []*pb.RowResponse } @@ -1470,9 +1628,19 @@ func (m *mockPilosa_QuerySQLServer) SetTrailer(md metadata.MD) { } func (m *mockPilosa_QuerySQLServer) Context() context.Context { - return context.Background() + return m.ctx } func (m *mockPilosa_QuerySQLServer) clearResults() { m.Results = m.Results[:0] } +func writeTestFile(t *testing.T, filename, content string) string { + fname := filepath.Join(t.TempDir(), filename) + f, err := os.Create(fname) + if err != nil { + panic(filename) + } + io.WriteString(f, content) + defer f.Close() + return fname +} diff --git a/server/server.go b/server/server.go index 3071e4040..c2dc433cd 100644 --- a/server/server.go +++ b/server/server.go @@ -85,8 +85,7 @@ type Command struct { pgserver *PostgresServer serverOptions []pilosa.ServerOption - - auth *authn.Auth + auth *authn.Auth } type CommandOption func(c *Command) error @@ -110,6 +109,8 @@ func OptCommandConfig(config *Config) CommandOption { defer c.Config.MustValidate() if c.Config != nil { c.Config.Etcd = config.Etcd + c.Config.Auth = config.Auth + c.Config.TLS = config.TLS return nil } c.Config = config @@ -481,7 +482,6 @@ func (m *Command) SetupServer() error { pilosa.OptServerStatsClient(statsClient), pilosa.OptServerURI(advertiseURI), pilosa.OptServerGRPCURI(advertiseGRPCURI), - pilosa.OptServerInternalClient(http.NewInternalClientFromURI(uri, c)), pilosa.OptServerClusterName(m.Config.Cluster.Name), pilosa.OptServerSerializer(proto.Serializer{}), pilosa.OptServerStorageConfig(m.Config.Storage), @@ -497,6 +497,12 @@ func (m *Command) SetupServer() error { serverOptions = append(serverOptions, m.serverOptions...) + if m.Config.Auth.Enable { + serverOptions = append(serverOptions, pilosa.OptServerInternalClient(http.NewInternalClientFromURI(uri, c, http.WithSecretKey(m.Config.Auth.SecretKey)))) + } else { + serverOptions = append(serverOptions, pilosa.OptServerInternalClient(http.NewInternalClientFromURI(uri, c))) + } + m.Server, err = pilosa.NewServer(serverOptions...) if err != nil { @@ -514,13 +520,6 @@ func (m *Command) SetupServer() error { // Tell server about its new API, which its client will need. m.Server.SetAPI(m.API) - m.grpcServer, err = NewGRPCServer( - OptGRPCServerAPI(m.API), - OptGRPCServerListener(m.grpcLn), - OptGRPCServerTLSConfig(m.tlsConfig), - OptGRPCServerLogger(m.logger), - OptGRPCServerStats(statsClient), - ) if err != nil { return errors.Wrap(err, "new grpc server") } @@ -539,7 +538,7 @@ func (m *Command) SetupServer() error { } ac := m.Config.Auth - m.auth, err = authn.NewAuth(m.logger, m.listenURI.String(), ac.Scopes, ac.AuthorizeURL, ac.TokenURL, ac.GroupEndpointURL, ac.LogoutURL, ac.ClientId, ac.ClientSecret, ac.HashKey, ac.BlockKey) + m.auth, err = authn.NewAuth(m.logger, m.listenURI.String(), ac.Scopes, ac.AuthorizeURL, ac.TokenURL, ac.GroupEndpointURL, ac.LogoutURL, ac.ClientId, ac.ClientSecret, ac.SecretKey) if err != nil { return errors.Wrap(err, "instantiating authN object") } @@ -562,6 +561,16 @@ func (m *Command) SetupServer() error { } + m.grpcServer, err = NewGRPCServer( + OptGRPCServerAPI(m.API), + OptGRPCServerListener(m.grpcLn), + OptGRPCServerTLSConfig(m.tlsConfig), + OptGRPCServerLogger(m.logger), + OptGRPCServerStats(statsClient), + OptGRPCServerAuth(m.auth), + OptGRPCServerPerm(&p), + ) + m.Handler, err = http.NewHandler( http.OptHandlerAllowedOrigins(m.Config.Handler.AllowedOrigins), http.OptHandlerAPI(m.API), diff --git a/server/server_internal_test.go b/server/server_internal_test.go new file mode 100644 index 000000000..e42b843dd --- /dev/null +++ b/server/server_internal_test.go @@ -0,0 +1,40 @@ +package server + +import ( + "fmt" + "testing" +) + +// unit tests for internal functions +func TestIsAllowed(t *testing.T) { + cases := []struct { + requested []string + allowed []string + expected bool + }{ + { + requested: []string{"a", "b", "c"}, + allowed: []string{"a", "b", "c", "d", "e"}, + expected: true, + }, + { + requested: []string{"a", "b", "c", "f"}, + allowed: []string{"a", "b", "c", "d", "e"}, + expected: false, + }, + { + requested: []string{"a", "b", "c"}, + allowed: []string{}, + expected: false, + }, + } + + for i, test := range cases { + t.Run(fmt.Sprint(i), func(t *testing.T) { + if res := isAllowed(test.requested, test.allowed); res != test.expected { + t.Errorf("expected %v, got %v", test.expected, res) + } + }) + } + +}