mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-10-06 10:57:51 +00:00
Merge pull request #1812 from molecula/54mir/authentication
[FB-1014] Authentication
This commit is contained in:
commit
8690160dd4
32 changed files with 1687 additions and 175 deletions
309
authn/authenticate.go
Normal file
309
authn/authenticate.go
Normal 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,
|
||||
}
|
||||
|
||||
}
|
||||
118
authn/authenticate_internal_test.go
Normal file
118
authn/authenticate_internal_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
28
cmd/keygen.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
31
ctl/keygen.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
3
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
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
56
lattice/src/App/AuthFlow/AuthFlow.module.scss
Normal file
56
lattice/src/App/AuthFlow/AuthFlow.module.scss
Normal 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;
|
||||
}
|
||||
20
lattice/src/App/AuthFlow/SignInButton.tsx
Normal file
20
lattice/src/App/AuthFlow/SignInButton.tsx
Normal 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;
|
||||
20
lattice/src/App/AuthFlow/SignOutButton.tsx
Normal file
20
lattice/src/App/AuthFlow/SignOutButton.tsx
Normal 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;
|
||||
30
lattice/src/App/AuthFlow/Signin.tsx
Normal file
30
lattice/src/App/AuthFlow/Signin.tsx
Normal 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;
|
||||
1
lattice/src/App/AuthFlow/index.ts
Normal file
1
lattice/src/App/AuthFlow/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Signin';
|
||||
58
lattice/src/Main.tsx
Normal file
58
lattice/src/Main.tsx
Normal 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;
|
||||
BIN
lattice/src/assets/bg-pattern.png
Normal file
BIN
lattice/src/assets/bg-pattern.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
16
lattice/src/assets/m-bug-alt.svg
Normal file
16
lattice/src/assets/m-bug-alt.svg
Normal 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 |
|
|
@ -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')
|
||||
);
|
||||
|
||||
|
|
|
|||
12
lattice/src/services/__mocks__/eventServices.tsx
Normal file
12
lattice/src/services/__mocks__/eventServices.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
const pilosa = {
|
||||
get: {
|
||||
auth() {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
userinfo() {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports.pilosa = pilosa;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
96
lattice/src/services/useAuth.test.tsx
Normal file
96
lattice/src/services/useAuth.test.tsx
Normal 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);
|
||||
});
|
||||
81
lattice/src/services/useAuth.tsx
Normal file
81
lattice/src/services/useAuth.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
33
lattice/src/shared/PrivateRoute/PrivateRoute.tsx
Normal file
33
lattice/src/shared/PrivateRoute/PrivateRoute.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue