diff --git a/authn/authenticate.go b/authn/authenticate.go new file mode 100644 index 000000000..8e5f9a43d --- /dev/null +++ b/authn/authenticate.go @@ -0,0 +1,309 @@ +// Copyright 2021 Molecula Corp. All rights reserved. + +// Package authn handles authentication +package authn + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/golang-jwt/jwt" + "github.com/gorilla/securecookie" + "github.com/molecula/featurebase/v2/logger" + "github.com/pkg/errors" + "golang.org/x/oauth2" +) + +// Auth holds state and helper methods needed for authentication +type Auth struct { + logger logger.Logger + cookieName string + refreshWithin time.Duration + hashKey []byte + blockKey []byte + secure *securecookie.SecureCookie + groupEndpoint string + logoutEndpoint string + fbURL string + oAuthConfig *oauth2.Config +} + +// 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{ + logger: logger, + cookieName: "molecula-chip", + refreshWithin: time.Minute * time.Duration(15), + groupEndpoint: groupEndpoint, + logoutEndpoint: logout, + fbURL: url, + oAuthConfig: &oauth2.Config{ + RedirectURL: fmt.Sprintf("%s/redirect", url), + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + }, + } + 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") + } + + auth.secure = securecookie.New(auth.hashKey, auth.blockKey) + + return auth, nil +} + +// CookieValue holds the value of an authenticated user's cookie +type CookieValue struct { + UserID string + UserName string + GroupMembership []Group + Token *oauth2.Token +} + +// Group holds group information for an authenticated user +type Group struct { + UserID string + GroupID string `json:"id"` + GroupName string `json:"displayName"` +} + +// 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 + } + 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 + } + } + } + if len(cookie.GroupMembership) == 0 { + return nil, errors.New("user is not part of any groups in identity provider") + } + return cookie.GroupMembership, nil + +} + +// Login redirects a user to login to their configured oAuth login endpoint +func (a *Auth) Login(w http.ResponseWriter, r *http.Request) { + authURL := a.oAuthConfig.AuthCodeURL(a.oAuthConfig.Endpoint.AuthURL) + http.Redirect(w, r, authURL, http.StatusTemporaryRedirect) +} + +// Logout sets the molecula-chip cookie to an empty cookie and redirects the +// user to a configured "logged out" endpoint +func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) { + newCookie := a.getEmptyCookie() + http.SetCookie(w, newCookie) + 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. +func (a *Auth) Redirect(w http.ResponseWriter, r *http.Request) { + code := r.FormValue("code") + token, err := a.getToken(code) + if err != nil { + http.Error(w, "Bad Request: 400", http.StatusBadRequest) + return + } + + cv, err := a.newCookieValue(token) + if err != nil || cv == nil { + http.Error(w, "Bad Request: 400", http.StatusBadRequest) + return + } + + a.setCookie(w, cv) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) +} + +// GetUserInfo gets and returns user info from a request +func (a *Auth) GetUserInfo(w http.ResponseWriter, r *http.Request) *UserInfo { + var resp UserInfo + cookie, err := a.readCookie(w, r) + if err != nil { + //add logging + return &resp + } + resp.UserID = cookie.UserID + resp.UserName = cookie.UserName + return &resp +} + +func (a *Auth) getToken(code string) (*oauth2.Token, error) { + token, err := a.oAuthConfig.Exchange(context.Background(), code) + if err != nil { + return nil, errors.Wrap(err, "exchanging auth code for token") + } + return token, nil +} + +func (a *Auth) newCookieValue(token *oauth2.Token) (*CookieValue, 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") + } + accessParsed, err := jwt.Parse(token.AccessToken, nil) + if accessParsed == nil || accessParsed.Claims == nil { + return nil, errors.Wrap(err, "parsing jwt claims from access tokens") + } + claims := accessParsed.Claims.(jwt.MapClaims) + + groups, err := a.getGroupMembership(token) + if err != nil { + return nil, errors.Wrap(err, "getting group membership") + } + // not needed at this point in the logic and makes the encoded cookie too large + token.AccessToken = "" + return &CookieValue{ + UserID: claims["oid"].(string), + UserName: claims["name"].(string), + GroupMembership: groups, + Token: token, + }, nil +} + +func (a *Auth) getGroupMembership(token *oauth2.Token) ([]Group, error) { + var groups []Group + var bearer = fmt.Sprintf("Bearer %s", token.AccessToken) + req, err := http.NewRequest("GET", a.groupEndpoint, nil) + if err != nil { + return groups, errors.Wrap(err, "creating new request to group endpoint") + } + + req.Header.Add("Authorization", bearer) + client := &http.Client{} + response, err := client.Do(req) + if err != nil { + return groups, errors.Wrap(err, "getting group membership info") + } + + defer response.Body.Close() + rawGroups, err := ioutil.ReadAll(response.Body) + if err != nil { + return 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, nil +} + +func (a *Auth) readCookie(w http.ResponseWriter, r *http.Request) (*CookieValue, error) { + cookie, err := r.Cookie(a.cookieName) + if err != nil { + return nil, errors.Wrap(err, "cookie not found") + } + + var value CookieValue + err = a.secure.Decode(a.cookieName, cookie.Value, &value) + if err != nil { + newCookie := a.getEmptyCookie() + http.SetCookie(w, newCookie) + return nil, errors.Wrap(err, "decoding cookie") + } + + return &value, nil +} + +func (a *Auth) setCookie(w http.ResponseWriter, cookie *CookieValue) error { + encoded, err := a.secure.Encode(a.cookieName, cookie) + if err != nil { + return errors.Wrap(err, "encoding CookieValue") + + } + newCookie := &http.Cookie{ + Name: a.cookieName, + Value: encoded, + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Expires: cookie.Token.Expiry, + } + http.SetCookie(w, newCookie) + return nil +} + +func (a *Auth) refreshToken(w http.ResponseWriter, cookie *CookieValue) 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.newCookieValue(newToken) + if err != nil { + errors.Wrap(err, "setting cookie") + } + + a.setCookie(w, cv) + } + + return nil +} + +func decodeHex(hexstr string) ([]byte, error) { + data, err := hex.DecodeString(hexstr) + if err != nil { + return nil, errors.Wrap(err, "decoding hex string to byte slice") + } + if len(data) != 32 { + return nil, errors.Wrap(err, "invalid key length") + } + 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 new file mode 100644 index 000000000..8f1821413 --- /dev/null +++ b/authn/authenticate_internal_test.go @@ -0,0 +1,118 @@ +package authn + +import ( + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/molecula/featurebase/v2/logger" + "golang.org/x/oauth2" +) + +func TestAuth(t *testing.T) { + var ( + 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"} + Key = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + ShortKey = "DEADBEEFD" + ) + + a, err := NewAuth( + logger.NewStandardLogger(os.Stdout), + "http://localhost:10101/", + Scopes, + AuthorizeURL, + TokenURL, + GroupEndpointURL, + LogoutURL, + 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 := CookieValue{ + 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) + if err != nil { + t.Errorf("expected no errors, got: %v", err) + } + + if w.Result().Cookies()[0].Value == "" { + t.Errorf("expected some value, got: %+v", w.Result().Cookies()[0].Value) + } + if w.Result().Cookies()[0].Path != "/" { + t.Errorf("expected path to be /, got: %+v", w.Result().Cookies()[0].Path) + } + }) + t.Run("GetEmptyCookie", func(t *testing.T) { + c := a.getEmptyCookie() + if c.Value != "" { + t.Errorf("expected empty cookie, got: %+v", c.Value) + } + }) + t.Run("KeyLength", func(t *testing.T) { + _, err := NewAuth( + logger.NewStandardLogger(os.Stdout), + "http://localhost:10101/", + Scopes, + AuthorizeURL, + TokenURL, + GroupEndpointURL, + LogoutURL, + ClientID, + ClientSecret, + Key, + ShortKey, + ) + if err == nil || !strings.Contains(err.Error(), "decoding block key") { + t.Errorf("expected error decoding block key got: %v", err) + } + }) + t.Run("NewCookieValue-BadAccessToken", func(t *testing.T) { + _, err := a.newCookieValue(&tokenAT) + if err == nil || !strings.Contains(err.Error(), "jwt claims") { + t.Errorf("expected failure regarding jwt claims, got: %v", err) + } + + }) + t.Run("CookieValue-NoAccessToken", func(t *testing.T) { + _, err := a.newCookieValue(&tokenNoAT) + if err == nil || !strings.Contains(err.Error(), "access token") { + t.Errorf("expected failure regarding access token, got: %v", err) + } + }) + +} diff --git a/authz/authorization.go b/authz/authorization.go index 77bd67ade..11bc9faac 100644 --- a/authz/authorization.go +++ b/authz/authorization.go @@ -19,46 +19,16 @@ import ( "io" "io/ioutil" + "github.com/molecula/featurebase/v2/authn" + "gopkg.in/yaml.v2" ) -type Auth struct { - // Enable AuthZ/AuthN for featurebase server - Enable bool `toml:"enable"` - - // Application/Client ID - ClientId string `toml:"client-id"` - - // Client Secret - ClientSecret string `toml:"client-secret"` - - // Authorize URL - AuthorizeURL string `toml:"authorize-url"` - - // Token URL - TokenURL string `toml:"token-url"` - - // Group Endpoint URL - GroupEndpointURL string `toml:"group-endpoint-url"` - - // Scope URL - ScopeURL string `toml:"scope-url"` - - // Permissions file for groups - PermissionsFile string `toml:"permissions"` -} - type GroupPermissions struct { Permissions map[string]map[string]string `yaml:"user-groups"` Admin string `yaml:"admin"` } -type Group struct { - UserID string - GroupID string `json:"id"` - GroupName string `json:"displayName"` -} - func (p *GroupPermissions) ReadPermissionsFile(permsFile io.Reader) (err error) { permsData, err := ioutil.ReadAll(permsFile) @@ -74,7 +44,7 @@ func (p *GroupPermissions) ReadPermissionsFile(permsFile io.Reader) (err error) return } -func (p *GroupPermissions) GetPermissions(groups []Group, index string) (permission string, errors error) { +func (p *GroupPermissions) GetPermissions(groups []authn.Group, index string) (permission string, errors error) { if admin := p.IsAdmin(groups); admin { return "admin", nil @@ -115,7 +85,7 @@ func (p *GroupPermissions) GetPermissions(groups []Group, index string) (permiss } } -func (p *GroupPermissions) IsAdmin(groups []Group) bool { +func (p *GroupPermissions) IsAdmin(groups []authn.Group) bool { for _, group := range groups { if p.Admin == group.GroupID { return true @@ -124,7 +94,7 @@ func (p *GroupPermissions) IsAdmin(groups []Group) bool { return false } -func (p *GroupPermissions) GetAuthorizedIndexList(groups []Group, desiredPermission string) (indexList []string) { +func (p *GroupPermissions) GetAuthorizedIndexList(groups []authn.Group, desiredPermission string) (indexList []string) { // if user is admin, find all indexes in permissions file and return them if admin := p.IsAdmin(groups); admin { for groupId := range p.Permissions { diff --git a/authz/authorization_test.go b/authz/authorization_test.go index dab33dbe1..bfda894a9 100644 --- a/authz/authorization_test.go +++ b/authz/authorization_test.go @@ -20,6 +20,7 @@ import ( "strings" "testing" + "github.com/molecula/featurebase/v2/authn" "github.com/molecula/featurebase/v2/authz" ) @@ -107,17 +108,20 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` // initializes groups that are returned from identity provider groupName := "name" userId := "user-id" - groupsList1 := []authz.Group{} - groupsList2 := []authz.Group{{userId, "fake-group", groupName}} - groupsList3 := []authz.Group{ - {userId, "dca35310-ecda-4f23-86cd-876aee55906b", groupName}, - {userId, "dca35310-ecda-4f23-86cd-876aee559900", groupName}, + 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}, } - groupsList4 := []authz.Group{{userId, "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", groupName}} + groupsList4 := []authn.Group{{UserID: userId, GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: groupName}} tests := []struct { yamlData string - groups []authz.Group + groups []authn.Group index string userAccess string err string @@ -201,12 +205,12 @@ admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` func TestAuth_IsAdmin(t *testing.T) { - group1 := []authz.Group{ - {"admin-user-id", "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", "admin-group"}, + group1 := []authn.Group{ + {UserID: "admin-user-id", GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "admin-group"}, } - group2 := []authz.Group{ - {"user-id", "dca35310-ecda-4f23-86cd-876aee55906b", "group-name"}, + group2 := []authn.Group{ + {UserID: "user-id", GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: "group-name"}, } groupPermissions := authz.GroupPermissions{ @@ -217,7 +221,7 @@ func TestAuth_IsAdmin(t *testing.T) { } tests := []struct { - groups []authz.Group + groups []authn.Group groupPermissions authz.GroupPermissions output bool }{ @@ -242,16 +246,16 @@ func TestAuth_IsAdmin(t *testing.T) { func TestAuth_GetAuthorizedIndexList(t *testing.T) { - group1 := []authz.Group{ - {"user-id", "dca35310-ecda-4f23-86cd-876aee55906b", "group-name"}, + group1 := []authn.Group{ + {UserID: "user-id", GroupID: "dca35310-ecda-4f23-86cd-876aee55906b", GroupName: "group-name"}, } - group2 := []authz.Group{ - {"admin-user-id", "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", "admin-group"}, + group2 := []authn.Group{ + {UserID: "admin-user-id", GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "admin-group"}, } - group3 := []authz.Group{ - {"user-id", "dca35310-ecda-4f23-86cd-876aee559900", "group-name"}, + group3 := []authn.Group{ + {UserID: "user-id", GroupID: "dca35310-ecda-4f23-86cd-876aee559900", GroupName: "group-name"}, } p := authz.GroupPermissions{ @@ -268,7 +272,7 @@ func TestAuth_GetAuthorizedIndexList(t *testing.T) { } tests := []struct { - groups []authz.Group + groups []authn.Group permission string output []string }{ diff --git a/cmd/keygen.go b/cmd/keygen.go new file mode 100644 index 000000000..9a4faa940 --- /dev/null +++ b/cmd/keygen.go @@ -0,0 +1,28 @@ +// Copyright 2021 Molecula Corp. All rights reserved. +package cmd + +import ( + "context" + "io" + + "github.com/molecula/featurebase/v2/ctl" + "github.com/spf13/cobra" +) + +func newKeygenCommand(stdin io.Reader, stdout io.Writer, stderr io.Writer) *cobra.Command { + cmd := ctl.NewKeygenCommand(stdin, stdout, stderr) + ccmd := &cobra.Command{ + Use: "keygen", + Short: "Generate keys for authentication.", + Long: ` +Generate hash and block keys to configure FeatureBase for Authentication. +`, + RunE: func(c *cobra.Command, args []string) error { + return cmd.Run(context.Background()) + }, + } + + flags := ccmd.Flags() + flags.IntVarP(&cmd.KeyLength, "length", "l", 32, "length of keys to produce") + return ccmd +} diff --git a/cmd/root.go b/cmd/root.go index 4ea6a30e0..c164bed97 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,7 @@ import ( "io" "strings" - "github.com/molecula/featurebase/v2" + pilosa "github.com/molecula/featurebase/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -62,6 +62,8 @@ at https://docs.molecula.cloud/. rc.AddCommand(newRBFCommand(stdin, stdout, stderr)) rc.AddCommand(newServeCmd(stdin, stdout, stderr)) rc.AddCommand(newHolderCmd(stdin, stdout, stderr)) + rc.AddCommand(newHolderCmd(stdin, stdout, stderr)) + rc.AddCommand(newKeygenCommand(stdin, stdout, stderr)) rc.SetOutput(stderr) return rc diff --git a/ctl/keygen.go b/ctl/keygen.go new file mode 100644 index 000000000..06cc797ad --- /dev/null +++ b/ctl/keygen.go @@ -0,0 +1,31 @@ +// Copyright 2021 Molecula Corp. All rights reserved. +package ctl + +import ( + "context" + "fmt" + "io" + + "github.com/gorilla/securecookie" + pilosa "github.com/molecula/featurebase/v2" +) + +// Keygen represents a command for generating crytographic keys. +type KeygenCommand struct { + CmdIO *pilosa.CmdIO + KeyLength int +} + +// NewKeygen returns a new instance of Keygen. +func NewKeygenCommand(stdin io.Reader, stdout, stderr io.Writer) *KeygenCommand { + return &KeygenCommand{ + CmdIO: pilosa.NewCmdIO(stdin, stdout, stderr), + } +} + +// Run keys to use for authentication . +func (kg *KeygenCommand) Run(_ context.Context) error { + fmt.Printf("hash-key = \"%+x\"\n", securecookie.GenerateRandomKey(kg.KeyLength)) + fmt.Printf("block-key = \"%+x\"\n", securecookie.GenerateRandomKey(kg.KeyLength)) + return nil +} diff --git a/ctl/server.go b/ctl/server.go index 086506f54..40cbfe4c9 100644 --- a/ctl/server.go +++ b/ctl/server.go @@ -116,6 +116,10 @@ func BuildServerFlags(cmd *cobra.Command, srv *server.Command) { flags.StringVar(&srv.Config.Auth.AuthorizeURL, "auth.authorize-url", srv.Config.Auth.AuthorizeURL, "Identity Provider's Authorize URL.") flags.StringVar(&srv.Config.Auth.TokenURL, "auth.token-url", srv.Config.Auth.TokenURL, "Identity Provider's Token URL.") 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.ScopeURL, "auth.scope-url", srv.Config.Auth.ScopeURL, "Identity Provider's Scope 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.PermissionsFile, "auth.permissions", srv.Config.Auth.PermissionsFile, "Permissions' file with group authorization.") + } diff --git a/go.mod b/go.mod index 58b7e60c2..3b36cd0a8 100644 --- a/go.mod +++ b/go.mod @@ -19,12 +19,14 @@ require ( github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/go-test/deep v1.0.7 github.com/gogo/protobuf v1.3.2 + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang/protobuf v1.3.3 github.com/google/go-cmp v0.5.5 github.com/google/uuid v1.1.4 // indirect github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect github.com/gorilla/handlers v1.3.0 github.com/gorilla/mux v1.7.0 + github.com/gorilla/securecookie v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.0 github.com/improbable-eng/grpc-web v0.13.0 github.com/lib/pq v1.8.0 @@ -53,6 +55,7 @@ require ( golang.org/x/exp v0.0.0-20201008143054-e3b2a7f2fdc7 golang.org/x/mod v0.4.2 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 google.golang.org/grpc v1.28.0 gopkg.in/yaml.v2 v2.3.0 diff --git a/go.sum b/go.sum index a60699bb5..97504ff52 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,8 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -172,6 +174,8 @@ github.com/gorilla/handlers v1.3.0 h1:tsg9qP3mjt1h4Roxp+M1paRjrVBfPSOpBuVclh6Ylu github.com/gorilla/handlers v1.3.0/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -464,6 +468,7 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -556,6 +561,7 @@ google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsb google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= diff --git a/http/handler.go b/http/handler.go index 7ef5eda37..9de92720e 100644 --- a/http/handler.go +++ b/http/handler.go @@ -29,6 +29,7 @@ import ( "github.com/gorilla/handlers" "github.com/gorilla/mux" 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" @@ -67,6 +68,8 @@ type Handler struct { middleware []func(http.Handler) http.Handler pprofCPUProfileBuffer *bytes.Buffer + + auth *authn.Auth } // externalPrefixFlag denotes endpoints that are intended to be exposed to clients. @@ -113,6 +116,13 @@ func OptHandlerAPI(api *pilosa.API) handlerOption { } } +func OptHandlerAuth(auth *authn.Auth) handlerOption { + return func(h *Handler) error { + h.auth = auth + return nil + } +} + func OptHandlerFileSystem(fs pilosa.FileSystem) handlerOption { return func(h *Handler) error { h.fileSystem = fs @@ -353,7 +363,7 @@ func (h *Handler) collectStats(next http.Handler) http.Handler { // latticeRoutes lists the frontend routes that do not directly correspond to // backend routes, and require special handling. -var latticeRoutes = []string{"/tables", "/query", "/querybuilder"} // TODO somehow pull this from some metadata in the lattice directory +var latticeRoutes = []string{"/tables", "/query", "/querybuilder", "/signin"} // TODO somehow pull this from some metadata in the lattice directory // newRouter creates a new mux http router. func newRouter(handler *Handler) http.Handler { @@ -450,6 +460,12 @@ func newRouter(handler *Handler) http.Handler { router.HandleFunc("/cpu-profile/start", handler.handleCPUProfileStart).Methods("GET").Name("CPUProfileStart") router.HandleFunc("/cpu-profile/stop", handler.handleCPUProfileStop).Methods("GET").Name("CPUProfileStop") + router.HandleFunc("/login", handler.handleLogin).Methods("GET").Name("Login") + router.HandleFunc("/logout", handler.handleLogout).Methods("GET").Name("Logout") + router.HandleFunc("/redirect", handler.handleRedirect).Methods("GET").Name("Redirect") + router.HandleFunc("/auth", handler.handleCheckAuthentication).Methods("GET").Name("CheckAuthentication") + router.HandleFunc("/userinfo", handler.handleUserInfo).Methods("GET").Name("UserInfo") + // Endpoints to support lattice UI embedded via statik. // The messiness here reflects the fact that assets live in a nontrivial // directory structure that is controlled externally. @@ -3379,3 +3395,73 @@ func (h *Handler) handlePostRestore(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) //nolint:errcheck } + +func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { + if h.auth == nil { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(http.StatusNoContent) + w.Write([]byte("Auth Off")) //nolint:errcheck + return + } + + h.auth.Login(w, r) +} + +func (h *Handler) handleRedirect(w http.ResponseWriter, r *http.Request) { + if h.auth == nil { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(http.StatusNoContent) + w.Write([]byte("Auth Off")) //nolint:errcheck + return + } + h.auth.Redirect(w, r) +} + +func (h *Handler) handleCheckAuthentication(w http.ResponseWriter, r *http.Request) { + if !validHeaderAcceptJSON(r.Header) { + http.Error(w, "JSON only acceptable response", http.StatusNotAcceptable) + return + } + if h.auth == nil { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(http.StatusNoContent) + w.Write([]byte("Auth Off")) //nolint:errcheck + return + } + groups, err := h.auth.Authenticate(w, r) + if groups == nil || err != nil { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(http.StatusForbidden) + return + } + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) //nolint:errcheck + +} + +func (h *Handler) handleUserInfo(w http.ResponseWriter, r *http.Request) { + if !validHeaderAcceptJSON(r.Header) { + http.Error(w, "JSON only acceptable response", http.StatusNotAcceptable) + return + } + if h.auth == nil { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(http.StatusNoContent) + w.Write([]byte("Auth Off")) //nolint:errcheck + return + } + if err := json.NewEncoder(w).Encode(h.auth.GetUserInfo(w, r)); err != nil { + h.logger.Errorf("writing user info: %s", err) + } +} + +func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { + if h.auth == nil { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(http.StatusNoContent) + w.Write([]byte("Auth Off")) //nolint:errcheck + return + } + h.auth.Logout(w, r) +} diff --git a/http/handler_internal_test.go b/http/handler_internal_test.go index e28924035..e9cfee87a 100644 --- a/http/handler_internal_test.go +++ b/http/handler_internal_test.go @@ -3,13 +3,24 @@ package http import ( "bytes" + "encoding/hex" "encoding/json" + "io/ioutil" + gohttp "net/http" + "net/http/httptest" + "net/url" + "os" "reflect" "strings" "testing" + "time" + "github.com/gorilla/securecookie" pilosa "github.com/molecula/featurebase/v2" + "github.com/molecula/featurebase/v2/authn" + "github.com/molecula/featurebase/v2/logger" "github.com/molecula/featurebase/v2/pql" + "golang.org/x/oauth2" ) // Test custom UnmarshalJSON for postIndexRequest object @@ -166,3 +177,402 @@ func TestFieldOptionValidation(t *testing.T) { } } } + +func readResponse(w *httptest.ResponseRecorder) ([]byte, error) { + res := w.Result() + defer res.Body.Close() + return ioutil.ReadAll(res.Body) +} + +func TestHandlerAuth(t *testing.T) { + type evaluate func(w *httptest.ResponseRecorder, data []byte) + type endpoint func(w gohttp.ResponseWriter, r *gohttp.Request) + var ( + 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"} + HashKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + BlockKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + ) + + hashKey, _ := hex.DecodeString("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") + blockKey, _ := hex.DecodeString("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") + + a, err := authn.NewAuth( + logger.NewStandardLogger(os.Stdout), + "http://localhost:10101/", + Scopes, + AuthorizeURL, + TokenURL, + GroupEndpointURL, + LogoutURL, + ClientId, + ClientSecret, + HashKey, + BlockKey, + ) + if err != nil { + t.Errorf("building auth object%s", err) + } + + h := Handler{ + auth: a, + } + + hOff := Handler{} + + token := oauth2.Token{ + TokenType: "Bearer", + RefreshToken: "abcdef", + Expiry: time.Now().Add(time.Hour), + } + + expiredToken := oauth2.Token{ + TokenType: "Bearer", + RefreshToken: "abcdef", + Expiry: time.Now(), + } + + grp := authn.Group{ + UserID: "snowstorm", + GroupID: "abcd123-A", + GroupName: "Romantic Painters", + } + + validCV := authn.CookieValue{ + UserID: "snowstorm", + UserName: "J.M.W. Turner", + GroupMembership: []authn.Group{grp}, + Token: &token, + } + + emptyCV := authn.CookieValue{ + UserID: "narcissus", + UserName: "Caravaggio", + GroupMembership: []authn.Group{}, + Token: &token, + } + expiredCV := authn.CookieValue{ + UserID: "narcissus", + UserName: "Caravaggio", + GroupMembership: []authn.Group{}, + 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) + + 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", + Path: "/", + Secure: true, + HttpOnly: true, + Expires: token.Expiry, + } + + tests := []struct { + name string + path string + kind string + cookie *gohttp.Cookie + handler endpoint + fn evaluate + }{ + { + name: "Login", + path: "/login", + kind: "type1", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleLogin(w, r) }, + 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)) + } + }, + }, + { + name: "Logout", + path: "/logout", + kind: "type1", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleLogout(w, r) }, + 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) + } + }, + }, + { + name: "Authenticate-Groups", + path: "/auth", + kind: "type1", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleCheckAuthentication(w, r) }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + if w.Result().StatusCode != 200 { + t.Errorf("expected http code 200, got: %+v", w.Result().StatusCode) + } + }, + }, + { + name: "Authenticate-NoGroups", + path: "/auth", + kind: "type1", + cookie: noGroupCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleCheckAuthentication(w, r) }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + // status forbidden + if w.Result().StatusCode != 403 { + 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) }, + 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" { + t.Errorf("expected http code 400, got: %+v", uinfo) + } + }, + }, + { + name: "UserInfo-NoCookie", + path: "/userinfo", + kind: "type1", + cookie: emptyCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleUserInfo(w, r) }, + 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) + } + }, + }, + + { + name: "Redirect-NoAuthCode", + path: "/redirect", + kind: "type1", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleRedirect(w, r) }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + if strings.Index(string(data), AuthorizeURL) != 9 { + if w.Result().StatusCode != 400 { + t.Errorf("expected http code 400, got: %+v", w.Result().StatusCode) + } + } + }, + }, + { + name: "Redirect-SomeAuthCode", + path: "/redirect", + kind: "type2", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { h.handleRedirect(w, r) }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + if strings.Index(string(data), AuthorizeURL) != 9 { + if w.Result().StatusCode != 400 { + t.Errorf("expected http code 400, got: %+v", w.Result().StatusCode) + } + } + }, + }, + { + name: "Login-AuthOff", + path: "/login", + kind: "type1", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleLogin(w, r) }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + if strings.Index(string(data), AuthorizeURL) != 9 { + if w.Result().StatusCode != 204 { + t.Errorf("expected http code 204, got: %+v", w.Result().StatusCode) + } + } + }, + }, + { + name: "Logout-AuthOff", + path: "/logout", + kind: "type1", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleLogout(w, r) }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + if strings.Index(string(data), AuthorizeURL) != 9 { + if w.Result().StatusCode != 204 { + t.Errorf("expected http code 204, got: %+v", w.Result().StatusCode) + } + } + }, + }, + { + name: "UserInfo-AuthOff", + path: "/userinfo", + kind: "type1", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleUserInfo(w, r) }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + if strings.Index(string(data), AuthorizeURL) != 9 { + if w.Result().StatusCode != 204 { + t.Errorf("expected http code 204, got: %+v", w.Result().StatusCode) + } + } + }, + }, + { + name: "Authenticate-AuthOff", + path: "/auth", + kind: "type1", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleCheckAuthentication(w, r) }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + if strings.Index(string(data), AuthorizeURL) != 9 { + if w.Result().StatusCode != 204 { + t.Errorf("expected http code 204, got: %+v", w.Result().StatusCode) + } + } + }, + }, + { + name: "Redirect-AuthOff", + path: "/redirect", + kind: "type1", + cookie: validCookie, + handler: func(w gohttp.ResponseWriter, r *gohttp.Request) { hOff.handleRedirect(w, r) }, + fn: func(w *httptest.ResponseRecorder, data []byte) { + if strings.Index(string(data), AuthorizeURL) != 9 { + if w.Result().StatusCode != 204 { + t.Errorf("expected http code 204, got: %+v", w.Result().StatusCode) + } + } + }, + }, + } + + for _, test := range tests { + switch test.kind { + case "type1": + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest(gohttp.MethodGet, test.path, nil) + w := httptest.NewRecorder() + r.AddCookie(test.cookie) + 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) + }) + case "type2": + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest(gohttp.MethodGet, test.path, nil) + w := httptest.NewRecorder() + r.Form = url.Values{} + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.Form.Add("code", "junk") + + 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 5b1e73345..033db191d 100644 --- a/install/featurebase.conf +++ b/install/featurebase.conf @@ -372,7 +372,8 @@ log-path = "/var/log/molecula/featurebase.log" # ============================================================================== # Enable/Disable AuthN/AuthZ for featurebase -# Can choose identity provider, pass authorize and user-info endpoints, and client id +# Can choose identity provider, defaults for Azure Active Directory +# Use provided keygen binary to generate hash and block keys with sufficient length and entropy # [auth] # enable = false # client-id = "" @@ -380,5 +381,8 @@ log-path = "/var/log/molecula/featurebase.log" # authorize-url = "" # token-url = "" # group-endpoint-url = "" -# scope-url = "" -# permissions = "" \ No newline at end of file +# logout-url = "" +# scopes = ["", ""] +# hash-key = "" +# block-key = "" +# permissions = "" diff --git a/lattice/src/App.tsx b/lattice/src/App.tsx index f465bd480..4a93cd6ff 100644 --- a/lattice/src/App.tsx +++ b/lattice/src/App.tsx @@ -1,58 +1,37 @@ -import React, { useEffect, useState } from 'react'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import { Route, Switch } from 'react-router-dom'; -import { darkTheme, lightTheme } from 'theme/'; -import { Home } from 'App/Home'; -import { Header } from 'shared/Header'; +import { BrowserRouter, Route, Switch } from 'react-router-dom'; import { MuiThemeProvider } from '@material-ui/core/styles'; -import { Nav } from 'shared/Nav'; -import { NotFound } from 'App/NotFound'; -import { MoleculaTablesContainer } from 'App/MoleculaTables'; -import { QueryContainer } from 'App/Query'; -import { QueryBuilderContainer } from 'App/QueryBuilder'; -import css from './App.module.scss'; + +import { useAuth } from 'services/useAuth'; +import PrivateRoute from 'shared/PrivateRoute/PrivateRoute'; +import { lightTheme } from 'theme/'; +import Main from 'Main'; +import Signin from 'App/AuthFlow/Signin'; const App = () => { - const [theme, setTheme] = useState( - localStorage.getItem('theme') || 'light' - ); - - useEffect(() => { - if(theme === 'dark') { - document.documentElement.setAttribute('data-theme', 'dark') - } else { - document.documentElement.removeAttribute('data-theme'); - } - }, [theme]); - - const onToggleTheme = () => { - const newTheme = theme === 'dark' ? 'light' : 'dark'; - setTheme(newTheme); - localStorage.setItem('theme', newTheme); - }; + const auth = useAuth(); return ( - - -
- -
-
-
-
- + ) : ( + // Auth is off, all routes are accessible + + )} + + )} + ); -} +}; export default App; diff --git a/lattice/src/App/AuthFlow/AuthFlow.module.scss b/lattice/src/App/AuthFlow/AuthFlow.module.scss new file mode 100644 index 000000000..5ac275622 --- /dev/null +++ b/lattice/src/App/AuthFlow/AuthFlow.module.scss @@ -0,0 +1,56 @@ +.main { + min-height: 100vh; + background-repeat: no-repeat; + background-image: linear-gradient( + to bottom, + rgba(250, 250, 250, 1), + rgba(250, 250, 250, 0.7) + ), + url(/assets/bg-pattern.png); + background-size: cover; + padding-bottom: 32px; +} + +.logoContainer { + text-align: center; +} + +.logo { + height: 85px; + margin: 16px; +} + +.loginForm { + width: 500px; + margin: 0 auto; + padding-top: 75px; +} + +.formError { + color: #f44336; + margin-bottom: 16px; +} + +.sso { + text-align: center; + padding: 24px 0 16px; +} + +.passwordField { + position: relative; + + .forgotPassword { + // [syang] Eww yes, I hate this + position: absolute; + right: 0; + z-index: 1; + } +} + +.backToSignIn { + padding: 24px 0 16px; +} + +.alert { + margin-bottom: 16px; +} diff --git a/lattice/src/App/AuthFlow/SignInButton.tsx b/lattice/src/App/AuthFlow/SignInButton.tsx new file mode 100644 index 000000000..7ebd9b8b7 --- /dev/null +++ b/lattice/src/App/AuthFlow/SignInButton.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Button } from '@material-ui/core'; + +interface Props { + children?: React.ReactNode; +} + +const SignInButton: React.FC = ({ children }) => { + const signinOnClick = (e) => { + window.location.href = '/login'; + }; + + return ( + + ); +}; + +export default SignInButton; diff --git a/lattice/src/App/AuthFlow/SignOutButton.tsx b/lattice/src/App/AuthFlow/SignOutButton.tsx new file mode 100644 index 000000000..3e76c22db --- /dev/null +++ b/lattice/src/App/AuthFlow/SignOutButton.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Button } from '@material-ui/core'; + +interface Props { + children?: React.ReactNode; +} + +const SignOutButton: React.FC = ({ children }) => { + const signoutOnClick = (e) => { + window.location.href = '/logout'; + }; + + return ( + + ); +}; + +export default SignOutButton; diff --git a/lattice/src/App/AuthFlow/Signin.tsx b/lattice/src/App/AuthFlow/Signin.tsx new file mode 100644 index 000000000..4a5bb8ce2 --- /dev/null +++ b/lattice/src/App/AuthFlow/Signin.tsx @@ -0,0 +1,30 @@ +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import CardHeader from '@material-ui/core/CardHeader'; + +import { ReactComponent as MLogo } from 'assets/m-bug-alt.svg'; +import css from './AuthFlow.module.scss'; +import SignInButton from './SignInButton'; + +function Signin(props) { + const renderLoginForm = () => ( + + + + + + + ); + + return ( +
+
+
+ +
+ {renderLoginForm()} +
+
+ ); +} +export default Signin; diff --git a/lattice/src/App/AuthFlow/index.ts b/lattice/src/App/AuthFlow/index.ts new file mode 100644 index 000000000..364a48925 --- /dev/null +++ b/lattice/src/App/AuthFlow/index.ts @@ -0,0 +1 @@ +export * from './Signin'; \ No newline at end of file diff --git a/lattice/src/Main.tsx b/lattice/src/Main.tsx new file mode 100644 index 000000000..b29410889 --- /dev/null +++ b/lattice/src/Main.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import { Route, Switch } from 'react-router-dom'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import { MuiThemeProvider } from '@material-ui/core/styles'; + +import { Header } from 'shared/Header'; +import { Nav } from 'shared/Nav'; +import { darkTheme, lightTheme } from 'theme/'; +import { Home } from 'App/Home'; +import { MoleculaTablesContainer } from 'App/MoleculaTables'; +import { NotFound } from 'App/NotFound'; +import { QueryContainer } from 'App/Query'; +import { QueryBuilderContainer } from 'App/QueryBuilder'; + +import css from './App.module.scss'; + +const Main = () => { + const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light'); + + useEffect(() => { + if (theme === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + }, [theme]); + + const onToggleTheme = () => { + const newTheme = theme === 'dark' ? 'light' : 'dark'; + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + }; + + return ( +
+ + +
+
+
+
+
+ +
+ ); +}; + +export default Main; diff --git a/lattice/src/assets/bg-pattern.png b/lattice/src/assets/bg-pattern.png new file mode 100644 index 000000000..23bdf09be Binary files /dev/null and b/lattice/src/assets/bg-pattern.png differ diff --git a/lattice/src/assets/m-bug-alt.svg b/lattice/src/assets/m-bug-alt.svg new file mode 100644 index 000000000..a0cc81bc1 --- /dev/null +++ b/lattice/src/assets/m-bug-alt.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/lattice/src/index.tsx b/lattice/src/index.tsx index 331a583a3..dccfe3cab 100644 --- a/lattice/src/index.tsx +++ b/lattice/src/index.tsx @@ -1,14 +1,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import App from './App'; -import { BrowserRouter as Router, Route } from 'react-router-dom'; +import { ProvideAuth } from 'services/useAuth'; + import * as serviceWorker from './serviceWorker'; import './index.scss'; +import App from './App'; ReactDOM.render( - - - , + + + + + , document.getElementById('root') ); diff --git a/lattice/src/services/__mocks__/eventServices.tsx b/lattice/src/services/__mocks__/eventServices.tsx new file mode 100644 index 000000000..a6203b244 --- /dev/null +++ b/lattice/src/services/__mocks__/eventServices.tsx @@ -0,0 +1,12 @@ +const pilosa = { + get: { + auth() { + return new Promise((resolve, reject) => {}); + }, + userinfo() { + return new Promise((resolve, reject) => {}); + }, + }, +}; + +module.exports.pilosa = pilosa; diff --git a/lattice/src/services/eventServices.tsx b/lattice/src/services/eventServices.tsx index 5ad7e19db..b2adcfd33 100644 --- a/lattice/src/services/eventServices.tsx +++ b/lattice/src/services/eventServices.tsx @@ -1,12 +1,13 @@ import axios from 'axios'; + import { baseURL } from './baseURL'; const api = axios.create({ baseURL, headers: { 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json' - } + Accept: 'application/json', + }, }); export const pilosa = { @@ -14,6 +15,12 @@ export const pilosa = { status() { return api.get('/status'); }, + auth() { + return api.get('/auth'); + }, + userinfo() { + return api.get('/userinfo'); + }, info() { return api.get('/info'); }, @@ -40,7 +47,7 @@ export const pilosa = { }, queryHistory() { return api.get('/query-history'); - } + }, }, post: { finishTransaction(id) { @@ -48,6 +55,6 @@ export const pilosa = { }, query(index, query) { return api.post(`/index/${index}/query`, query); - } - } + }, + }, }; diff --git a/lattice/src/services/useAuth.test.tsx b/lattice/src/services/useAuth.test.tsx new file mode 100644 index 000000000..2cf784786 --- /dev/null +++ b/lattice/src/services/useAuth.test.tsx @@ -0,0 +1,96 @@ +import { AxiosResponse } from 'axios'; +import { act } from 'react-dom/test-utils'; +import ReactDOM from 'react-dom'; + +import { ProvideAuth, useAuth } from 'services/useAuth'; +import { pilosa } from './eventServices'; + +jest.mock('./eventServices'); + +const AUTHENTICATED = 'Authenticated'; +const NOTAUTHED = 'Not Authed'; +const AUTHOFF = 'Auth off'; + +function TestUseAuthComponent() { + const auth = useAuth(); + + if (auth.isAuthOn === true && auth.isAuthenticated === true) { + return
{AUTHENTICATED}
; + } else if (auth.isAuthOn === true && auth.isAuthenticated === false) { + return
{NOTAUTHED}
; + } else { + return
{AUTHOFF}
; + } +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test('test useAuth - expect authenticated', async () => { + const mockResponse: AxiosResponse = { + status: 200, + data: 'OK', + statusText: '', + headers: {}, + config: {}, + }; + const root = document.createElement('root'); + await act(async () => { + jest.spyOn(pilosa.get, 'auth').mockResolvedValueOnce(mockResponse); + ReactDOM.render( + + + , + root + ); + }); + expect(pilosa.get.auth).toHaveBeenCalledTimes(1); + expect(root.innerHTML).toContain(AUTHENTICATED); +}); + +test('test useAuth - expect not authed', async () => { + const mockResponse: AxiosResponse = { + status: 200, + data: '', + statusText: '', + headers: {}, + config: {}, + }; + + const root = document.createElement('root'); + await act(async () => { + jest.spyOn(pilosa.get, 'auth').mockResolvedValueOnce(mockResponse); + ReactDOM.render( + + + , + root + ); + }); + expect(pilosa.get.auth).toHaveBeenCalledTimes(1); + expect(root.innerHTML).toContain(NOTAUTHED); +}); + +test('test useAuth - expect auth off', async () => { + const mockResponse: AxiosResponse = { + status: 204, + data: '', + statusText: '', + headers: {}, + config: {}, + }; + + const root = document.createElement('root'); + await act(async () => { + jest.spyOn(pilosa.get, 'auth').mockResolvedValueOnce(mockResponse); + ReactDOM.render( + + + , + root + ); + }); + expect(pilosa.get.auth).toHaveBeenCalledTimes(1); + expect(root.innerHTML).toContain(AUTHOFF); +}); diff --git a/lattice/src/services/useAuth.tsx b/lattice/src/services/useAuth.tsx new file mode 100644 index 000000000..0c94cb733 --- /dev/null +++ b/lattice/src/services/useAuth.tsx @@ -0,0 +1,81 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +import { pilosa } from './eventServices'; + +const authContext = createContext({}); + +// Provider component that wraps your app and makes auth object ... +// ... available to any child component that calls useAuth(). +export function ProvideAuth({ children }) { + const auth = useProvideAuth(); + return {children}; +} + +// Hook for child components to get the auth object ... +// ... and re-render when it changes. +export const useAuth = () => { + return useContext(authContext); +}; + +export interface IUser { + userid: string; + username: string; +} + +// Provider hook that creates auth object and handles state +function useProvideAuth() { + const [user, setUser] = useState(undefined); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isAuthOn, setIsAuthOn] = useState(true); + + const userinfo = () => { + pilosa.get.userinfo().then((userinfoRes) => { + if (userinfoRes.data.userid && userinfoRes.data.username) { + setUser(userinfoRes.data); + } else { + setUser(undefined); + } + }); + }; + + // Subscribe to user on mount + // Because this sets state in the callback it will cause any ... + // ... component that utilizes this hook to re-render with the ... + // ... latest auth object. + useEffect(() => { + pilosa.get + .auth() + .then((res) => { + if (res.status === 204) { + // Authentication is off + setIsAuthOn(false); + } else { + // Turn on Authentication + setIsAuthOn(true); + + if (res.data === 'OK') { + // User is authenticated + setIsAuthenticated(true); + + // get userinfo + userinfo(); + } else { + // User not authenticated + setIsAuthenticated(false); + } + } + }) + .finally(() => { + setIsLoading(false); + }); + }, []); + + return { + isAuthenticated, + isLoading, + isAuthOn, + user, + userinfo, + }; +} diff --git a/lattice/src/shared/Header/Header.tsx b/lattice/src/shared/Header/Header.tsx index ed2c51bc4..b32e72e63 100644 --- a/lattice/src/shared/Header/Header.tsx +++ b/lattice/src/shared/Header/Header.tsx @@ -1,12 +1,17 @@ -import React, { FC } from 'react'; -import AppBar from '@material-ui/core/AppBar'; -import Toolbar from '@material-ui/core/Toolbar'; -import { Link } from 'react-router-dom'; -import { ReactComponent as MoleculaLogo } from 'assets/lightTheme/MoleculaLogo.svg'; -import { ReactComponent as MoleculaLogoDark } from 'assets/darkTheme/MoleculaLogo.svg'; -import { ThemeToggle } from 'shared/ThemeToggle'; -import { useTheme } from '@material-ui/core/styles'; -import css from './Header.module.scss'; +import SignOutButton from "App/AuthFlow/SignOutButton"; +import { ReactComponent as MoleculaLogoDark } from "assets/darkTheme/MoleculaLogo.svg"; +import { ReactComponent as MoleculaLogo } from "assets/lightTheme/MoleculaLogo.svg"; +import { FC } from "react"; +import { Link } from "react-router-dom"; +import { useAuth } from "services/useAuth"; +import { ThemeToggle } from "shared/ThemeToggle"; + +import AppBar from "@material-ui/core/AppBar"; +import Button from "@material-ui/core/Button"; +import { useTheme } from "@material-ui/core/styles"; +import Toolbar from "@material-ui/core/Toolbar"; + +import css from "./Header.module.scss"; type HeaderProps = { onToggleTheme: () => void; @@ -14,7 +19,8 @@ type HeaderProps = { export const Header: FC = ({ onToggleTheme }) => { const theme = useTheme(); - const isDark = theme.palette.type === 'dark'; + const isDark = theme.palette.type === "dark"; + const auth = useAuth(); return ( = ({ onToggleTheme }) => { /> + + {auth.isAuthenticated ? ( +
+ {auth.user && ( + + )} + +
+ ) : null}
diff --git a/lattice/src/shared/PrivateRoute/PrivateRoute.tsx b/lattice/src/shared/PrivateRoute/PrivateRoute.tsx new file mode 100644 index 000000000..5a76677fc --- /dev/null +++ b/lattice/src/shared/PrivateRoute/PrivateRoute.tsx @@ -0,0 +1,33 @@ +import { Redirect, Route } from 'react-router-dom'; + +import { useAuth } from 'services/useAuth'; + +function PrivateRoute({ component: Component, ...rest }) { + const auth = useAuth(); + + return ( + { + if (auth.isAuthenticated) { + // If the user is authenticated, render the component + return ; + } else { + // If the user is not authenticated, redirect to sign in page + return ( + + ); + } + }} + /> + ); +} + +export default PrivateRoute; diff --git a/server/config.go b/server/config.go index 22a300312..a551f65b3 100644 --- a/server/config.go +++ b/server/config.go @@ -233,8 +233,23 @@ type Config struct { // Toggles /schema/details endpoint. If off, it returns empty. SchemaDetailsOn bool `toml:"schema-details-on"` - // Enable AuthZ/AuthN - Auth authz.Auth `toml:"auth"` + Auth Auth +} + +type Auth struct { + // Enable AuthZ/AuthN for featurebase server + Enable bool `toml:"enable"` + + ClientId string `toml:"client-id"` + ClientSecret string `toml:"client-secret"` + AuthorizeURL string `toml:"authorize-url"` + TokenURL string `toml:"token-url"` + 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"` + PermissionsFile string `toml:"permissions"` } // Namespace returns the namespace to use based on the Future flag. @@ -609,7 +624,9 @@ func (c *Config) ValidateAuth() (errors []error) { "AuthorizeURL": c.Auth.AuthorizeURL, "TokenURL": c.Auth.TokenURL, "GroupEndpointURL": c.Auth.GroupEndpointURL, - "ScopeURL": c.Auth.ScopeURL, + "LogoutURL": c.Auth.LogoutURL, + "HashKey": c.Auth.HashKey, + "BlockKey": c.Auth.BlockKey, } for name, value := range authConfig { @@ -618,6 +635,12 @@ func (c *Config) ValidateAuth() (errors []error) { continue } + if name == "HashKey" || name == "BlockKey" { + if len(value) != 64 { + errors = append(errors, fmt.Errorf("invalid key length for %s. exp %d, got %d", name, 64, len(value))) + } + } + if strings.Contains(name, "URL") { _, err := url.ParseRequestURI(value) if err != nil { @@ -626,6 +649,11 @@ func (c *Config) ValidateAuth() (errors []error) { } } } + + if len(c.Auth.Scopes) == 0 { + errors = append(errors, fmt.Errorf("must provide scope for authentication with IdP - for access and refresh token")) + } + return errors } @@ -666,6 +694,7 @@ func (c *Config) ValidatePermissions(permsFile io.Reader) (errors []error) { if p.Admin == "" { errors = append(errors, fmt.Errorf("empty string for admin in permissions file: %s", c.Auth.PermissionsFile)) + } return errors diff --git a/server/config_internal_test.go b/server/config_internal_test.go index 11148dfad..4af9007fe 100644 --- a/server/config_internal_test.go +++ b/server/config_internal_test.go @@ -8,8 +8,6 @@ import ( "os" "strings" "testing" - - "github.com/molecula/featurebase/v2/authz" ) type addrs struct{ bind, advertise string } @@ -281,19 +279,25 @@ func TestConfig_validateAddrsGRPC(t *testing.T) { func TestConfig_validateAuth(t *testing.T) { errorMesgEmpty := "empty string" errorMesgURL := "invalid URL" + errorMesgScope := "must provide scope" + errorMesgKey := "invalid key length" validTestURL := "https://url.com/" validClientID := "clientid" validClientSecret := "clientSecret" + validKey := "3db6665be8b860af422155acf2346d4fcb46678fca42e60d934abe0b7ce43600" invalidURL := "not-a-url" emptyString := "" + validStringSlice := []string{"https://graph.microsoft.com/.default", "offline_access"} + validString := "asdfqwer1234asdfzxcv" + var emptySlice []string + enable := true disable := false tests := []struct { expErrs []string - input authz.Auth + input Auth }{ - { // Auth enabled, all configs are set to empty string []string{ @@ -303,56 +307,109 @@ func TestConfig_validateAuth(t *testing.T) { errorMesgEmpty, errorMesgEmpty, errorMesgEmpty, + errorMesgEmpty, + errorMesgEmpty, }, - authz.Auth{ + Auth{ Enable: enable, ClientId: emptyString, ClientSecret: emptyString, AuthorizeURL: emptyString, TokenURL: emptyString, GroupEndpointURL: emptyString, - ScopeURL: emptyString, + LogoutURL: emptyString, + Scopes: validStringSlice, + HashKey: emptyString, + BlockKey: emptyString, }, }, { - // Auth enabled, some strings are set to invalid URL + // Auth enabled, keys are invalid length []string{ - errorMesgURL, + errorMesgKey, + errorMesgKey, }, - authz.Auth{ - Enable: enable, - ClientId: validClientID, - ClientSecret: validClientSecret, - AuthorizeURL: invalidURL, - TokenURL: validTestURL, - GroupEndpointURL: validTestURL, - ScopeURL: validTestURL, - }, - }, - { - // Auth enabled, all configs are set properly - []string{}, - authz.Auth{ + Auth{ Enable: enable, ClientId: validClientID, ClientSecret: validClientSecret, AuthorizeURL: validTestURL, TokenURL: validTestURL, GroupEndpointURL: validTestURL, - ScopeURL: validTestURL, + LogoutURL: validTestURL, + Scopes: validStringSlice, + HashKey: validString, + BlockKey: validString, }, }, { - // Auth disabled, all configs are set to empty string + // Auth enabled, some URLs are set to invalid URL + []string{ + errorMesgURL, + errorMesgURL, + errorMesgURL, + }, + Auth{ + Enable: enable, + ClientId: validClientID, + ClientSecret: validClientSecret, + AuthorizeURL: validTestURL, + TokenURL: invalidURL, + GroupEndpointURL: invalidURL, + LogoutURL: invalidURL, + Scopes: validStringSlice, + HashKey: validKey, + BlockKey: validKey, + }, + }, + { + // Auth enabled, all configs are set properly except scope + []string{ + errorMesgScope, + }, + Auth{ + Enable: enable, + ClientId: validClientID, + ClientSecret: validClientSecret, + AuthorizeURL: validTestURL, + TokenURL: validTestURL, + GroupEndpointURL: validTestURL, + LogoutURL: validTestURL, + Scopes: emptySlice, + HashKey: validKey, + BlockKey: validKey, + }, + }, + { + // Auth enabled, all configs are set properly []string{}, - authz.Auth{ + Auth{ + Enable: enable, + ClientId: validClientID, + ClientSecret: validClientSecret, + AuthorizeURL: validTestURL, + TokenURL: validTestURL, + GroupEndpointURL: validTestURL, + LogoutURL: validTestURL, + Scopes: validStringSlice, + HashKey: validKey, + BlockKey: validKey, + }, + }, + { + // Auth disabled, some configs are set to values + []string{}, + Auth{ Enable: disable, ClientId: emptyString, - ClientSecret: emptyString, + ClientSecret: validString, AuthorizeURL: emptyString, TokenURL: emptyString, - GroupEndpointURL: emptyString, - ScopeURL: emptyString, + GroupEndpointURL: invalidURL, + LogoutURL: validTestURL, + Scopes: validStringSlice, + HashKey: validKey, + BlockKey: emptyString, }, }, } diff --git a/server/server.go b/server/server.go index 677e74f93..e9bb10a68 100644 --- a/server/server.go +++ b/server/server.go @@ -29,6 +29,7 @@ import ( "golang.org/x/sync/errgroup" pilosa "github.com/molecula/featurebase/v2" + "github.com/molecula/featurebase/v2/authn" "github.com/molecula/featurebase/v2/authz" "github.com/molecula/featurebase/v2/boltdb" "github.com/molecula/featurebase/v2/encoding/proto" @@ -82,6 +83,8 @@ type Command struct { pgserver *PostgresServer serverOptions []pilosa.ServerOption + + auth *authn.Auth } type CommandOption func(c *Command) error @@ -223,22 +226,6 @@ func (m *Command) Start() (err error) { return errors.Wrap(err, "setting resource limits") } - if m.Config.Auth.Enable { - m.Config.MustValidateAuth() - - permsFile, err := os.Open(m.Config.Auth.PermissionsFile) - if err != nil { - return err - } - - defer permsFile.Close() - - var p authz.GroupPermissions - if err = p.ReadPermissionsFile(permsFile); err != nil { - return err - } - } - // Initialize server. if err = m.Server.Open(); err != nil { return errors.Wrap(err, "opening server") @@ -536,6 +523,35 @@ func (m *Command) SetupServer() error { return errors.Wrap(err, "new grpc server") } + if m.Config.Auth.Enable { + m.Config.MustValidateAuth() + permsFile, err := os.Open(m.Config.Auth.PermissionsFile) + if err != nil { + return err + } + defer permsFile.Close() + + var p authz.GroupPermissions + if err = p.ReadPermissionsFile(permsFile); err != nil { + return err + } + + 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) + if err != nil { + return errors.Wrap(err, "instantiating authN object") + } + + // disable postgres binding if auth is enabled + m.Config.Postgres.Bind = "" + + // TLS must be enabled if auth is + if m.Config.TLS.CertificatePath == "" || m.Config.TLS.CertificateKeyPath == "" || m.Config.TLS.CACertPath == "" { + return fmt.Errorf("transport layer security (TLS) is not configured properly. TLS is required when AuthN/Z is enabled, current configuration: %v", m.Config.TLS) + } + + } + m.Handler, err = http.NewHandler( http.OptHandlerAllowedOrigins(m.Config.Handler.AllowedOrigins), http.OptHandlerAPI(m.API), @@ -544,6 +560,7 @@ func (m *Command) SetupServer() error { http.OptHandlerListener(m.ln, m.Config.Advertise), http.OptHandlerCloseTimeout(m.closeTimeout), http.OptHandlerMiddleware(m.grpcServer.middleware(m.Config.Handler.AllowedOrigins)), + http.OptHandlerAuth(m.auth), ) return errors.Wrap(err, "new handler") }