Merge pull request #1812 from molecula/54mir/authentication

[FB-1014] Authentication
This commit is contained in:
Samir Patel 2022-01-04 09:43:56 -05:00 • committed by GitHub
commit 8690160dd4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1687 additions and 175 deletions

309
authn/authenticate.go Normal file
View file

@ -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,
}
}

View file

@ -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)
}
})
}

View file

@ -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 {

View file

@ -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
}{

28
cmd/keygen.go Normal file
View file

@ -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
}

View file

@ -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

31
ctl/keygen.go Normal file
View file

@ -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
}

View file

@ -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.")
}

3
go.mod
View file

@ -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

6
go.sum
View file

@ -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=

View file

@ -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)
}

View file

@ -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)
})
}
}
}

View file

@ -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 = ""
# logout-url = ""
# scopes = ["", ""]
# hash-key = ""
# block-key = ""
# permissions = ""

View file

@ -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<string>(
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 (
<MuiThemeProvider theme={theme === 'light' ? lightTheme : darkTheme}>
<CssBaseline />
<Header onToggleTheme={onToggleTheme} />
<div className={css.container}>
<div className={css.layout}>
<Nav />
<div className={css.mainContent}>
<BrowserRouter>
{auth.isLoading ? (
// Loading, retreiving auth status
<div></div>
) : (
// Loading done, display app based on auth status
<MuiThemeProvider theme={lightTheme}>
{auth.isAuthOn ? (
// Auth is on, hide the routes with PrivateRoute
<Switch>
<Route exact path="/" component={Home} />
<Route path="/tables/:id?" component={MoleculaTablesContainer} />
<Route exact path="/query" component={QueryContainer} />
<Route exact path="/querybuilder" component={QueryBuilderContainer} />
<Route component={NotFound} />
<Route exact path="/signin" render={(props) => <Signin {...props}></Signin>} />
<PrivateRoute path="/" component={Main} />
</Switch>
</div>
</div>
</div>
</MuiThemeProvider>
) : (
// Auth is off, all routes are accessible
<Route path="/" component={Main} />
)}
</MuiThemeProvider>
)}
</BrowserRouter>
);
}
};
export default App;

View file

@ -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;
}

View file

@ -0,0 +1,20 @@
import React from 'react';
import { Button } from '@material-ui/core';
interface Props {
children?: React.ReactNode;
}
const SignInButton: React.FC<Props> = ({ children }) => {
const signinOnClick = (e) => {
window.location.href = '/login';
};
return (
<Button variant="contained" color="primary" size="large" onClick={signinOnClick} fullWidth>
Sign in
</Button>
);
};
export default SignInButton;

View file

@ -0,0 +1,20 @@
import React from 'react';
import { Button } from '@material-ui/core';
interface Props {
children?: React.ReactNode;
}
const SignOutButton: React.FC<Props> = ({ children }) => {
const signoutOnClick = (e) => {
window.location.href = '/logout';
};
return (
<Button variant="contained" color="secondary" onClick={signoutOnClick}>
Sign out
</Button>
);
};
export default SignOutButton;

View file

@ -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 = () => (
<Card>
<CardHeader subheader={'Sign in to continue'} />
<CardContent>
<SignInButton />
</CardContent>
</Card>
);
return (
<div className={css.main}>
<div className={css.loginForm}>
<div className={css.logoContainer}>
<MLogo className={css.logo} />
</div>
{renderLoginForm()}
</div>
</div>
);
}
export default Signin;

View file

@ -0,0 +1 @@
export * from './Signin';

58
lattice/src/Main.tsx Normal file
View file

@ -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<string>(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 (
<div>
<MuiThemeProvider theme={theme === 'light' ? lightTheme : darkTheme}>
<CssBaseline />
<Header onToggleTheme={onToggleTheme} />
<div className={css.container}>
<div className={css.layout}>
<Nav />
<div className={css.mainContent}>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/tables/:id?" component={MoleculaTablesContainer} />
<Route exact path="/query" component={QueryContainer} />
<Route exact path="/querybuilder" component={QueryBuilderContainer} />
<Route component={NotFound} />
</Switch>
</div>
</div>
</div>
</MuiThemeProvider>
</div>
);
};
export default Main;

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 276 313" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<polygon id="path-1" points="0 0.35 275.231 0.35 275.231 312.94 0 312.94"></polygon>
</defs>
<g id="Group-3">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Clip-2"></g>
<path d="M275.231,86.005 C275.231,81.175 271.809,75.246 267.625,72.831 L145.222,2.162 C141.038,-0.254 134.193,-0.254 130.01,2.162 L7.606,72.831 C3.423,75.246 0,81.175 0,86.005 L0,227.345 C0,232.175 3.423,238.103 7.606,240.518 L130.01,311.188 C134.193,313.603 141.038,313.603 145.222,311.188 L267.625,240.518 C271.809,238.103 275.231,232.175 275.231,227.345 L275.231,86.005" id="Fill-1" fill="#084E83" mask="url(#mask-2)"></path>
</g>
<path d="M143.926,42.423 C138.489,39.277 131.526,41.13 128.376,46.569 C125.226,52.008 127.082,58.97 132.52,62.119 L216.669,110.849 L216.669,208.127 C216.669,214.412 221.764,219.507 228.049,219.507 C234.334,219.507 239.429,214.412 239.429,208.127 L239.429,97.728 L143.926,42.423" id="Fill-4" fill="#C3D4DD"></path>
<path d="M83.364,72.494 C80.213,77.933 82.07,84.895 87.508,88.044 L171.657,136.774 L171.657,234.053 C171.657,240.338 176.751,245.433 183.036,245.433 C189.321,245.433 194.416,240.338 194.416,234.053 L194.416,123.653 L98.913,68.349 C93.477,65.202 86.513,67.055 83.364,72.494" id="Fill-5" fill="#48B5CD"></path>
<path d="M38.185,98.634 C37.203,100.33 36.708,102.173 36.656,104.003 C36.633,105.008 36.819,208.157 36.819,208.157 C36.819,214.442 41.913,219.537 48.199,219.537 C54.484,219.537 59.578,214.442 59.578,208.157 L59.578,124.13 L81.632,136.915 L81.632,234.127 C81.632,240.412 86.727,245.507 93.012,245.507 C99.297,245.507 104.392,240.412 104.392,234.127 L104.392,150.11 L126.645,163.011 L126.645,260.017 C126.645,266.301 131.739,271.396 138.024,271.396 C144.309,271.396 149.404,266.301 149.404,260.017 L149.404,149.846 L53.735,94.489 C48.299,91.342 41.334,93.196 38.185,98.634" id="Fill-6" fill="#FFFFFF"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -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(
<Router>
<Route path="/" component={App} />
</Router>,
<React.StrictMode>
<ProvideAuth>
<App />
</ProvideAuth>
</React.StrictMode>,
document.getElementById('root')
);

View file

@ -0,0 +1,12 @@
const pilosa = {
get: {
auth() {
return new Promise((resolve, reject) => {});
},
userinfo() {
return new Promise((resolve, reject) => {});
},
},
};
module.exports.pilosa = pilosa;

View file

@ -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);
}
}
},
},
};

View file

@ -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 <div>{AUTHENTICATED}</div>;
} else if (auth.isAuthOn === true && auth.isAuthenticated === false) {
return <div>{NOTAUTHED}</div>;
} else {
return <div>{AUTHOFF}</div>;
}
}
beforeEach(() => {
jest.clearAllMocks();
});
test('test useAuth - expect authenticated', async () => {
const mockResponse: AxiosResponse<any> = {
status: 200,
data: 'OK',
statusText: '',
headers: {},
config: {},
};
const root = document.createElement('root');
await act(async () => {
jest.spyOn(pilosa.get, 'auth').mockResolvedValueOnce(mockResponse);
ReactDOM.render(
<ProvideAuth>
<TestUseAuthComponent />
</ProvideAuth>,
root
);
});
expect(pilosa.get.auth).toHaveBeenCalledTimes(1);
expect(root.innerHTML).toContain(AUTHENTICATED);
});
test('test useAuth - expect not authed', async () => {
const mockResponse: AxiosResponse<any> = {
status: 200,
data: '',
statusText: '',
headers: {},
config: {},
};
const root = document.createElement('root');
await act(async () => {
jest.spyOn(pilosa.get, 'auth').mockResolvedValueOnce(mockResponse);
ReactDOM.render(
<ProvideAuth>
<TestUseAuthComponent />
</ProvideAuth>,
root
);
});
expect(pilosa.get.auth).toHaveBeenCalledTimes(1);
expect(root.innerHTML).toContain(NOTAUTHED);
});
test('test useAuth - expect auth off', async () => {
const mockResponse: AxiosResponse<any> = {
status: 204,
data: '',
statusText: '',
headers: {},
config: {},
};
const root = document.createElement('root');
await act(async () => {
jest.spyOn(pilosa.get, 'auth').mockResolvedValueOnce(mockResponse);
ReactDOM.render(
<ProvideAuth>
<TestUseAuthComponent />
</ProvideAuth>,
root
);
});
expect(pilosa.get.auth).toHaveBeenCalledTimes(1);
expect(root.innerHTML).toContain(AUTHOFF);
});

View file

@ -0,0 +1,81 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { pilosa } from './eventServices';
const authContext = createContext<any>({});
// 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 <authContext.Provider value={auth}>{children}</authContext.Provider>;
}
// 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<IUser | undefined>(undefined);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuthOn, setIsAuthOn] = useState<boolean>(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,
};
}

View file

@ -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<HeaderProps> = ({ onToggleTheme }) => {
const theme = useTheme();
const isDark = theme.palette.type === 'dark';
const isDark = theme.palette.type === "dark";
const auth = useAuth();
return (
<AppBar
@ -38,6 +44,22 @@ export const Header: FC<HeaderProps> = ({ onToggleTheme }) => {
/>
</div>
</div>
{auth.isAuthenticated ? (
<div>
{auth.user && (
<Button
style={{
backgroundColor: "transparent",
pointerEvents: "none",
}}
>
{auth.user.username}
</Button>
)}
<SignOutButton />
</div>
) : null}
</div>
</Toolbar>
</AppBar>

View file

@ -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 (
<Route
{...rest}
render={(props) => {
if (auth.isAuthenticated) {
// If the user is authenticated, render the component
return <Component {...rest} {...props} />;
} else {
// If the user is not authenticated, redirect to sign in page
return (
<Redirect
to={{
pathname: "/signin",
state: {
from: props.location,
},
}}
/>
);
}
}}
/>
);
}
export default PrivateRoute;

View file

@ -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

View file

@ -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,
},
},
}

View file

@ -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")
}