FB-1378: Use IP whitelisting for ingest authentication and authorization (#2070)

* Use IP whitelisting for ingest
For ingest, use configured IPs to authenticate the requests.
Auth-token will no longer be used for requests from ingest consumers.
If IP in request is in configured IPs, authenticate and authorize as an admin.
If IP in request is not in configured IPs, proceed with the standard authentication/authorization using ADD.

* need to remove port from client IP

* addressed review comments
This commit is contained in:
souhailanoor 2022-05-20 14:57:05 -05:00 committed by GitHub
parent 62971f55dd
commit 3986e202bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 844 additions and 90 deletions

View file

@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
@ -61,22 +62,23 @@ type Groups struct {
// Auth holds state, configuration, and utilities needed for authentication.
type Auth struct {
logger logger.Logger
cookieName string
secretKey []byte
groupEndpoint string
logoutEndpoint string
fbURL string // fbURL is the domain featurebase is hosted on, used for post logout redirection
oAuthConfig *oauth2.Config
cacheTTL time.Duration // cacheTTL is used to determine if a cached item should be refreshed or not
tokenTTR time.Duration // tokenTTR (time to refresh) is used to determine if a token should be refreshed or not
tokenCache map[string]cachedToken // tokenCache is a map of accessToken -> *oauth2.Token which we can use to refresh the tokens
groupsCache map[string]cachedGroups // groupsCache is a map of accessToken -> group memberships
lastCacheClean time.Time // last cache clean is the time that the cache was last cleaned
logger logger.Logger
cookieName string
secretKey []byte
groupEndpoint string
logoutEndpoint string
fbURL string // fbURL is the domain featurebase is hosted on, used for post logout redirection
oAuthConfig *oauth2.Config
cacheTTL time.Duration // cacheTTL is used to determine if a cached item should be refreshed or not
tokenTTR time.Duration // tokenTTR (time to refresh) is used to determine if a token should be refreshed or not
tokenCache map[string]cachedToken // tokenCache is a map of accessToken -> *oauth2.Token which we can use to refresh the tokens
groupsCache map[string]cachedGroups // groupsCache is a map of accessToken -> group memberships
lastCacheClean time.Time // last cache clean is the time that the cache was last cleaned
allowedNetworks []net.IPNet // list of allowed networks for ingest
}
// NewAuth instantiates and returns a new Auth struct
func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenURL, groupEndpoint, logout, clientID, clientSecret, secretKey string) (auth *Auth, err error) {
func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenURL, groupEndpoint, logout, clientID, clientSecret, secretKey string, configuredIPs []string) (auth *Auth, err error) {
auth = &Auth{
logger: logger,
cookieName: CookieName,
@ -103,6 +105,13 @@ func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenUR
if auth.secretKey, err = decodeHex(secretKey); err != nil {
return nil, errors.Wrap(err, "decoding secret key")
}
// convert IPs and add them to allowed networks
err = auth.convertIP(configuredIPs)
if err != nil {
return nil, err
}
return auth, nil
}
@ -336,3 +345,39 @@ func decodeHex(hexstr string) ([]byte, error) {
}
return data, nil
}
func (a *Auth) convertIP(configuredIPs []string) error {
sz := len(configuredIPs)
nets := make([]net.IPNet, sz)
for i, ip := range configuredIPs {
// skip empty strings
if ip == "" {
sz--
continue
}
// for IPs passed without a subnet, append /32 to only allow 1 IP
// this step is needed because ParseCIDR method assumes a CIDR address
if !strings.Contains(ip, "/") {
ip = ip + "/32"
}
_, subnet, err := net.ParseCIDR(ip)
if err != nil {
return errors.Wrapf(err, "parsing CIDR for %v", ip)
}
nets[i] = *subnet
}
a.allowedNetworks = nets[:sz]
return nil
}
// if IP is in allowed networks, then return true to grant admin permissions
func (a *Auth) CheckAllowedNetworks(clientIP string) bool {
clientIP = strings.Split(clientIP, ":")[0]
convertedIP := net.ParseIP(clientIP)
for _, network := range a.allowedNetworks {
if network.Contains(convertedIP) {
return true
}
}
return false
}

View file

@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
@ -33,6 +34,7 @@ func NewTestAuth(t *testing.T) *Auth {
LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout"
Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"}
Key = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF"
configuredIPs = []string{}
)
a, err := NewAuth(
@ -46,6 +48,7 @@ func NewTestAuth(t *testing.T) *Auth {
ClientID,
ClientSecret,
Key,
configuredIPs,
)
if err != nil {
t.Fatalf("building auth object%s", err)
@ -147,6 +150,7 @@ func TestAuth(t *testing.T) {
"e9088663-eb08-41d7-8f65-efb5f54bbb71",
"DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
"DEADBEEFD",
[]string{},
)
if err == nil || !strings.Contains(err.Error(), "decoding secret key") {
t.Fatalf("expected error decoding secret key got: %v", err)
@ -648,3 +652,101 @@ func (s *ServerTransportStream) SetTrailer(md metadata.MD) error {
_ = md
return nil
}
func TestCheckAllowedNetworks(t *testing.T) {
tests := []struct {
requestIP string
configuredIPs []string
isAdmin bool
}{
{
requestIP: "10.0.0.1",
configuredIPs: []string{"10.0.0.1"},
isAdmin: true,
},
{
requestIP: "10.0.0.3",
configuredIPs: []string{"10.0.0.1", "10.0.0.2"},
isAdmin: false,
},
{
requestIP: "10.0.0.2",
configuredIPs: []string{"10.0.0.1/30"},
isAdmin: true,
},
// it is possible for the client IP to have a port
{
requestIP: "10.0.0.2:22",
configuredIPs: []string{"10.0.0.1/30"},
isAdmin: true,
},
{
requestIP: "10.1.0.3",
configuredIPs: []string{"10.0.0.1/32"},
isAdmin: false,
},
{
requestIP: "10.0.0.254",
configuredIPs: []string{"10.0.0.1/24"},
isAdmin: true,
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("network-%d", i), func(t *testing.T) {
a := NewTestAuth(t)
if err := a.convertIP(test.configuredIPs); err != nil {
t.Fatalf("failed to convert IPs from strings to net.IP: %v", err)
}
got := a.CheckAllowedNetworks(test.requestIP)
if got != test.isAdmin {
t.Fatalf("expected %v, got %v", test.isAdmin, got)
}
})
}
}
func TestConvertIP(t *testing.T) {
tests := []struct {
configuredIPs []string
convertedIPs []net.IPNet
}{
{
configuredIPs: []string{"10.0.0.1"},
convertedIPs: []net.IPNet{
{IP: net.ParseIP("10.0.0.1"), Mask: net.CIDRMask(32, 32)},
},
},
{
configuredIPs: []string{"10.0.0.1/30"},
convertedIPs: []net.IPNet{
{IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(30, 32)},
},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("network-%d", i), func(t *testing.T) {
a := NewTestAuth(t)
if err := a.convertIP(test.configuredIPs); err != nil {
t.Fatalf("failed to convert IPs from strings to net.IP: %v", err)
}
if len(a.allowedNetworks) != len(test.convertedIPs) {
t.Fatalf("expected len of %v networks, got %v", len(test.convertedIPs), len(a.allowedNetworks))
}
for i := range a.allowedNetworks {
expected, got := test.convertedIPs[i], a.allowedNetworks[i]
if got.IP.String() != expected.IP.String() {
t.Fatalf("for IP, expected %v, got %v", expected.IP, got.IP)
}
if got.Mask.String() != expected.Mask.String() {
t.Fatalf("for mask, expected %v, got %v", expected.Mask.String(), got.Mask.String())
}
}
})
}
}

View file

@ -641,6 +641,7 @@ func TestImport_AuthOn(t *testing.T) {
auth.ClientId,
auth.ClientSecret,
auth.SecretKey,
[]string{},
)
if err != nil {
t.Fatal(err)

View file

@ -108,6 +108,7 @@ func BuildServerFlags(cmd *cobra.Command, srv *server.Command) {
flags.StringVar(&srv.Config.Auth.SecretKey, "auth.secret-key", srv.Config.Auth.SecretKey, "Secret key used for auth.")
flags.StringVar(&srv.Config.Auth.PermissionsFile, "auth.permissions", srv.Config.Auth.PermissionsFile, "Permissions' file with group authorization.")
flags.StringVar(&srv.Config.Auth.QueryLogPath, "auth.query-log-path", srv.Config.Auth.QueryLogPath, "Path to log user queries")
flags.StringSliceVar(&srv.Config.Auth.ConfiguredIPs, "auth.configured-ips", srv.Config.Auth.ConfiguredIPs, "List of configured IPs allowed for ingest")
flags.BoolVar(&srv.Config.DataDog.Enable, "datadog.enable", false, "enable continuous profiling with DataDog cloud service, Note you must have DataDog agent installed")
flags.StringVar(&srv.Config.DataDog.Service, "datadog.service", "default-service", "The Datadog service name, for example my-web-app")

View file

@ -93,6 +93,23 @@ var externalPrefixFlag = map[string]bool{
"version": true,
}
const (
// OriginalIPHeader is the original IP for client
// It is used mainly for authenticating on remote nodes
// ForwardedIPHeader gets updated to the node's IP
// when requests are forward to other nodes in the cluster
OriginalIPHeader = "X-Molecula-Original-IP"
// ForwardedIPHeader is part of the standard header
// it is used to identify the originating IP of a client
ForwardedIPHeader = "X-Forwarded-For"
// AllowedNetworksGroupName is used for the admin group authorization
// when authentication is completed through checking the client IP
// against the allowed networks
AllowedNetworksGroupName = "allowed-networks"
)
type errorResponse struct {
Error string `json:"error"`
}
@ -581,35 +598,69 @@ func (h *Handler) chkInternal(handler http.HandlerFunc) http.HandlerFunc {
}
}
func (h *Handler) chkAllowedNetworks(r *http.Request) (bool, context.Context) {
// for every request, get IP of the request and check against configured IPs
reqIP := GetIP(r)
if reqIP == "" {
return false, r.Context()
}
// if client IP is in allowed networks
// add it to the context for key X-Molecula-Original-IP
if h.auth.CheckAllowedNetworks(reqIP) {
ctx := context.WithValue(r.Context(), OriginalIPHeader, reqIP)
return true, ctx
}
return false, r.Context()
}
func (h *Handler) chkAuthN(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if h.auth != nil {
uinfo, err := h.auth.Authenticate(r.Context(), getToken(r))
// if IP is in allowed networks, then serve the request
allowedNetwork, ctx := h.chkAllowedNetworks(r)
if allowedNetwork {
handler.ServeHTTP(w, r.WithContext(ctx))
return
}
uinfo, err := h.auth.Authenticate(ctx, getToken(r))
if err != nil {
http.Error(w, errors.Wrap(err, "authenticating").Error(), http.StatusUnauthorized)
return
}
// just in case it got refreshed
h.auth.SetCookie(w, uinfo.Token, uinfo.Expiry)
ctx = context.WithValue(ctx, "token", r.Header["Authorization"])
}
ctx := context.WithValue(r.Context(), "token", r.Header["Authorization"])
handler.ServeHTTP(w, r.WithContext(ctx))
}
}
func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// if auth isn't turned on, just serve the request
if h.auth == nil {
handler.ServeHTTP(w, r)
return
}
// check if IP is in allowed networks, if yes give it admin permissions
allowedNetwork, ctx := h.chkAllowedNetworks(r)
if allowedNetwork {
ctx = context.WithValue(ctx, contextKeyGroupMembership, []string{AllowedNetworksGroupName, h.permissions.Admin})
handler.ServeHTTP(w, r.WithContext(ctx))
return
}
// make a copy of the requested permissions
lperm := perm
// check if the user is authenticated
uinfo, err := h.auth.Authenticate(r.Context(), getToken(r))
uinfo, err := h.auth.Authenticate(ctx, getToken(r))
if err != nil {
http.Error(w, errors.Wrap(err, "authenticating").Error(), http.StatusForbidden)
return
@ -618,7 +669,7 @@ func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http
h.auth.SetCookie(w, uinfo.Token, uinfo.Expiry)
// put the user's groups in the context
ctx := context.WithValue(r.Context(), contextKeyGroupMembership, uinfo.Groups)
ctx = context.WithValue(ctx, contextKeyGroupMembership, uinfo.Groups)
ctx = context.WithValue(ctx, "token", "Bearer "+uinfo.Token)
// unlikely h.permissions will be nil, but we'll check to be safe
@ -630,7 +681,7 @@ func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http
// figure out what the user is querying for
queryString := ""
queryRequest := r.Context().Value(contextKeyQueryRequest)
queryRequest := ctx.Value(contextKeyQueryRequest)
if req, ok := queryRequest.(*QueryRequest); ok {
queryString = req.Query
@ -701,10 +752,27 @@ func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http
}
func GetIP(r *http.Request) string {
forwarded := r.Header.Get("X-FORWARDED-FOR")
if forwarded != "" {
return forwarded
// check if original IP was set in the request
og := r.Header.Get(OriginalIPHeader)
if og != "" {
return og
}
// check if original IP is in the context
if ogIP, ok := r.Context().Value(OriginalIPHeader).(string); ok && ogIP != "" {
return ogIP
}
// X-Forwarded-For can have multiple IPs
// the first IP will always be the originating client IP
// the remaining IPs will be for any proxies the request went through
forwarded := r.Header.Get(ForwardedIPHeader)
forwardedList := strings.Split(forwarded, ",")
if forwardedList[0] != "" {
return forwardedList[0]
}
return r.RemoteAddr
}
@ -888,6 +956,40 @@ func headerAcceptRoaringRow(header http.Header) bool {
return false
}
func (h *Handler) filterSchema(schema []*IndexInfo, g []authn.Group) []*IndexInfo {
if !h.permissions.IsAdmin(g) {
var filtered []*IndexInfo
allowed := h.permissions.GetAuthorizedIndexList(g, authz.Read)
for _, s := range schema {
for _, index := range allowed {
if s.Name == index {
filtered = append(filtered, s)
break
}
}
}
schema = filtered
}
return schema
}
func (h *Handler) getGroupMembership(r *http.Request) (g []authn.Group) {
// if IP is in allowed networks, then give admin group membership
if h.auth.CheckAllowedNetworks(GetIP(r)) {
g = []authn.Group{
{
GroupID: h.permissions.Admin,
GroupName: AllowedNetworksGroupName,
},
}
return g
}
// check if group membership was already set in request when auth token was obtained
g = r.Context().Value(contextKeyGroupMembership).([]authn.Group)
return g
}
// handleGetSchema handles GET /schema requests.
func (h *Handler) handleGetSchema(w http.ResponseWriter, r *http.Request) {
if !validHeaderAcceptJSON(r.Header) {
@ -906,24 +1008,12 @@ func (h *Handler) handleGetSchema(w http.ResponseWriter, r *http.Request) {
// if auth is turned on, filter response to only include authorized indexes
if h.auth != nil {
g := r.Context().Value(contextKeyGroupMembership)
g := h.getGroupMembership(r)
if g == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !h.permissions.IsAdmin(g.([]authn.Group)) {
var filtered []*IndexInfo
allowed := h.permissions.GetAuthorizedIndexList(g.([]authn.Group), authz.Read)
for _, s := range schema {
for _, index := range allowed {
if s.Name == index {
filtered = append(filtered, s)
break
}
}
}
schema = filtered
}
schema = h.filterSchema(schema, g)
}
if err := json.NewEncoder(w).Encode(Schema{Indexes: schema}); err != nil {
@ -952,25 +1042,14 @@ func (h *Handler) handleGetSchemaDetails(w http.ResponseWriter, r *http.Request)
// if auth is turned on, filter response to only include authorized indexes
if h.auth != nil {
g := r.Context().Value(contextKeyGroupMembership)
g := h.getGroupMembership(r)
if g == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !h.permissions.IsAdmin(g.([]authn.Group)) {
var filtered []*IndexInfo
allowed := h.permissions.GetAuthorizedIndexList(g.([]authn.Group), authz.Read)
for _, s := range schema {
for _, index := range allowed {
if s.Name == index {
filtered = append(filtered, s)
break
}
}
}
schema = filtered
}
schema = h.filterSchema(schema, g)
}
if err := json.NewEncoder(w).Encode(Schema{Indexes: schema}); err != nil {
h.logger.Printf("write schema response error: %s", err)
}

View file

@ -3,6 +3,7 @@ package pilosa
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
@ -188,6 +189,19 @@ func readResponse(w *httptest.ResponseRecorder) ([]byte, error) {
return ioutil.ReadAll(res.Body)
}
// common variables used for testing auth
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"
ConfiguredIPs = []string{}
)
func TestAuthentication(t *testing.T) {
type evaluate func(w *httptest.ResponseRecorder, data []byte)
type endpoint func(w http.ResponseWriter, r *http.Request)
@ -221,34 +235,10 @@ func TestAuthentication(t *testing.T) {
}))
defer groupSrv.Close()
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 = groupSrv.URL
LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout"
Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"}
SecretKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF"
)
GroupEndpointURL = groupSrv.URL
secretKey, _ := hex.DecodeString(Key)
secretKey, _ := hex.DecodeString("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF")
a, err := authn.NewAuth(
logger.NewStandardLogger(os.Stdout),
"http://localhost:10101/",
Scopes,
AuthorizeURL,
TokenURL,
GroupEndpointURL,
LogoutURL,
ClientId,
ClientSecret,
SecretKey,
)
if err != nil {
t.Errorf("building auth object%s", err)
}
a := NewTestAuth(t)
h := Handler{
logger: logger.NewStandardLogger(os.Stdout),
@ -781,17 +771,6 @@ func TestChkInternal(t *testing.T) {
func NewTestAuth(t *testing.T) *authn.Auth {
t.Helper()
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"
)
a, err := authn.NewAuth(
logger.NewStandardLogger(os.Stdout),
"http://localhost:10101/",
@ -803,6 +782,7 @@ func NewTestAuth(t *testing.T) *authn.Auth {
ClientID,
ClientSecret,
Key,
ConfiguredIPs,
)
if err != nil {
t.Fatalf("building auth object%s", err)
@ -846,3 +826,172 @@ func TestHandleGetDiskUsage(t *testing.T) {
t.Fatalf("expected %v, got %v", http.StatusOK, resp.StatusCode)
}
}
func TestAuthzAllowedIPs(t *testing.T) {
tests := []struct {
configuredIPs []string
clientIP string
statusCode int
permission authz.Permission
}{
// client IP is in configured IP list
{
configuredIPs: []string{"10.0.0.0", "10.0.0.1", "10.0.2.0/32"},
clientIP: "10.0.0.0",
statusCode: http.StatusOK,
permission: authz.Admin,
},
// client IP is in configured IP list, testing with CIDR address
{
configuredIPs: []string{"10.0.0.0/30"},
clientIP: "10.0.0.1",
statusCode: http.StatusOK,
permission: authz.Write,
},
// client IP has multiple IPs in X-Forwarded-For header
// originating IP is in configured IP list
{
configuredIPs: []string{"10.0.0.0", "10.0.0.1/32", "10.0.0.2"},
clientIP: "10.0.0.2,10.0.0.255",
statusCode: http.StatusOK,
permission: authz.Read,
},
// client IP has multiple IPs in X-Forwarded-For header
// originating IP is not in configured IP list
{
configuredIPs: []string{"10.0.0.0/30"},
clientIP: "10.0.0.255,10.0.0.2",
statusCode: http.StatusForbidden,
permission: authz.Read,
},
// client IP is not in configured IP list
{
configuredIPs: []string{"10.0.0.0", "10.0.0.1", "10.0.2.0/32"},
clientIP: "10.0.0.3",
statusCode: http.StatusForbidden,
permission: authz.Write,
},
// client IP is not in configured IP list
// X-Forwarded-For header is an empty string
{
configuredIPs: []string{"10.0.0.0", "10.0.0.1", "10.0.2.0/32"},
clientIP: "",
statusCode: http.StatusForbidden,
permission: authz.Write,
},
}
testingHandler := func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("good"))
}
permissions1 := `"user-groups":
"dca35310-ecda-4f23-86cd-876aee559900":
"test": "write"
admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"`
for i, test := range tests {
t.Run(fmt.Sprintf("ChkAuthz-%d", i), func(t *testing.T) {
ConfiguredIPs = test.configuredIPs
a := NewTestAuth(t)
h := Handler{
logger: logger.NewStandardLogger(os.Stdout),
queryLogger: logger.NewStandardLogger(os.Stdout),
auth: a,
}
var p authz.GroupPermissions
if err := p.ReadPermissionsFile(strings.NewReader(permissions1)); err != nil {
t.Errorf("Error: %s", err)
}
h.permissions = &p
r := httptest.NewRequest("GET", "/index/authz-abcd", nil)
r.Header.Set(ForwardedIPHeader, test.clientIP)
w := httptest.NewRecorder()
handler := h.chkAuthZ(testingHandler, authz.Read)
handler(w, r)
resp := w.Result()
if resp.StatusCode != test.statusCode {
t.Fatalf("expected %v, got %v", test.statusCode, resp.StatusCode)
}
})
}
}
func TestAuthnAllowedIPs(t *testing.T) {
IPList := []string{"10.0.0.0", "10.0.0.1", "10.0.0.2"}
ValidForwardedIP := "10.0.0.0, 10.0.0.3, 10.0.0.4"
InvalidForwardedIP := "10.0.0.3, 10.0.0.4"
tests := []struct {
configuredIPs []string
clientIP string
statusCode int
secretKey string
}{
// test client IP was in configured IP list - happy path
{
configuredIPs: IPList,
clientIP: IPList[0],
statusCode: http.StatusOK,
},
// test empty configured IP list
{
configuredIPs: []string{""},
clientIP: IPList[0],
statusCode: http.StatusUnauthorized,
},
// test client IP is not in configured IP list
{
configuredIPs: IPList,
clientIP: "10.0.0.4",
statusCode: http.StatusUnauthorized,
},
// test multiple client IPs in X-forwarded-IP
// originating IP is in configured IP list
{
configuredIPs: IPList,
clientIP: ValidForwardedIP,
statusCode: http.StatusOK,
},
// test multiple client IPs in X-forwarded-IP
// originating IP is not in configured IP list
{
configuredIPs: IPList,
clientIP: InvalidForwardedIP,
statusCode: http.StatusUnauthorized,
},
}
testingHandler := func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("good"))
}
for i, test := range tests {
t.Run(fmt.Sprintf("ChkAuthn-%d", i), func(t *testing.T) {
ConfiguredIPs = test.configuredIPs
a := NewTestAuth(t)
h := Handler{
logger: logger.NewStandardLogger(os.Stdout),
queryLogger: logger.NewStandardLogger(os.Stdout),
auth: a,
}
r := httptest.NewRequest("GET", "/index/authn-abcd", nil)
r.Header.Set(ForwardedIPHeader, test.clientIP)
r = r.WithContext(context.Background())
w := httptest.NewRecorder()
handler := h.chkAuthN(testingHandler)
handler(w, r)
resp := w.Result()
if resp.StatusCode != test.statusCode {
t.Fatalf("expected %v, got %v", test.statusCode, resp.StatusCode)
}
})
}
}

View file

@ -5,8 +5,12 @@ import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net"
"net/http"
gohttp "net/http"
"os"
"path"
"reflect"
"sort"
"strings"
@ -469,3 +473,195 @@ func TestTranslationHandlers(t *testing.T) {
}
}
}
func TestAuthAllowedNetworks(t *testing.T) {
permissions1 := `
"user-groups":
"dca35310-ecda-4f23-86cd-876aee55906b":
"test": "read"
admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"`
tmpDir := t.TempDir()
permissionsPath := path.Join(tmpDir, "test-permissions.yaml")
err := ioutil.WriteFile(permissionsPath, []byte(permissions1), 0666)
if err != nil {
t.Fatalf("failed to write permissions file: %v", err)
}
queryLogPath := path.Join(tmpDir, "query.log")
_, err = os.Create(queryLogPath)
if err != nil {
t.Fatal(err)
}
validIP := "10.0.0.2"
clusterSize := 3
commandOpts := make([][]server.CommandOption, clusterSize)
configs := make([]*server.Config, clusterSize)
for i := range configs {
conf := server.NewConfig()
configs[i] = conf
conf.TLS.CertificatePath = "./testdata/certs/localhost.crt"
conf.TLS.CertificateKeyPath = "./testdata/certs/localhost.key"
conf.Auth.Enable = true
conf.Auth.ClientId = "e9088663-eb08-41d7-8f65-efb5f54bbb71"
conf.Auth.ClientSecret = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF"
conf.Auth.AuthorizeURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize"
conf.Auth.TokenURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize"
conf.Auth.GroupEndpointURL = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true"
conf.Auth.LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout"
conf.Auth.RedirectBaseURL = "https://localhost:10101/"
conf.Auth.QueryLogPath = queryLogPath
conf.Auth.SecretKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF"
conf.Auth.PermissionsFile = permissionsPath
conf.Auth.Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"}
conf.Auth.ConfiguredIPs = []string{validIP}
commandOpts[i] = append(commandOpts[i], server.OptCommandConfig(conf))
}
c := test.MustRunCluster(t, clusterSize, commandOpts...)
defer c.Close()
m := c.GetPrimary()
index := "allowed-networks-index"
keyedIndex := "allowed-networks-index-keyed"
field := "field1"
// needed for key translation
nameBytes, err := json.Marshal([]string{"a", "b", "c"})
if err != nil {
t.Fatalf("marshalling json: %v", err)
}
names := string(nameBytes)
schema := `
{
"index-name": "allowed-networks-index-keyed",
"primary-key-type": "string",
"index-action": "create",
"fields": [
{
"field-name": "stringset",
"field-type": "string",
"field-options": {
"cache-type": "ranked",
"cache-size": 100000
}
}
]
}
`
IPTests := []struct {
TestName string
ClientIP string
StatusCode int
}{
{TestName: "ValidIP", ClientIP: validIP, StatusCode: http.StatusOK},
{TestName: "InvalidIP", ClientIP: "10.0.1.1", StatusCode: http.StatusForbidden},
}
tests := []struct {
testName string
method string
url string
body string
}{
{
testName: "Post-Index",
method: "POST",
url: fmt.Sprintf("%s/index/%s", m.URL(), index),
body: "",
},
{
testName: "Post-field",
method: "POST",
url: fmt.Sprintf("%s/index/%s/field/%s", m.URL(), index, field),
body: "",
},
{
testName: "Get-Schema",
method: "GET",
url: fmt.Sprintf("%s/schema", m.URL()),
body: "",
},
{
testName: "Post-Schema",
method: "POST",
url: fmt.Sprintf("%s/internal/schema", m.URL()),
body: schema,
},
{
testName: "Get-Shards",
method: "GET",
url: fmt.Sprintf("%s/internal/index/%s/shards", m.URL(), keyedIndex),
body: "",
},
{
testName: "Post-CreateKeys",
method: "POST",
url: fmt.Sprintf("%s/internal/translate/index/%s/keys/create", m.URL(), keyedIndex),
body: names,
},
{
testName: "Get-MemoryUsage",
method: "GET",
url: fmt.Sprintf("%s/internal/mem-usage", m.URL()),
body: "",
},
{
testName: "Get-Status",
method: "GET",
url: fmt.Sprintf("%s/status", m.URL()),
body: "",
},
}
for _, ipTest := range IPTests {
for _, test := range tests {
t.Run(ipTest.TestName+"-"+test.testName, func(t *testing.T) {
var req *gohttp.Request
if test.body != "" {
req, err = gohttp.NewRequest(test.method, test.url, strings.NewReader(test.body))
if err != nil {
t.Fatal(err)
}
} else {
req, err = gohttp.NewRequest(test.method, test.url, nil)
if err != nil {
t.Fatal(err)
}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", ipTest.ClientIP)
resp, err := gohttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("failed to send request: %v", err)
}
if resp.StatusCode != ipTest.StatusCode {
t.Fatalf("expected %v response code, got %v, body: %v", ipTest.StatusCode, resp.StatusCode, resp.Body)
}
if ipTest.StatusCode == 200 {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading resp body :%v", err)
}
if test.testName == "Post-CreateKeys" {
var results map[string]uint64
err = json.Unmarshal([]byte(body), &results)
if err != nil {
t.Fatalf("unmarshalling result: %v", err)
}
if len(results) != 3 {
t.Fatalf("finding keys before any were set: expected 3 results, got %d (%q)", len(results), results)
}
}
}
})
}
}
}

View file

@ -64,6 +64,7 @@ func GetAuthToken(t *testing.T) string {
ClientID,
ClientSecret,
Key,
[]string{},
)
if err != nil {
t.Fatalf("NewAuth: %v", err)

View file

@ -154,6 +154,12 @@ func AddAuthToken(ctx context.Context, req *http.Request) *http.Request {
// UserInfo.Token is not prefixed with "Bearer"
req.Header.Set("Authorization", "Bearer "+uinfo.(*authn.UserInfo).Token)
}
// set ogIP to request for remote calls
if ogIP, ok := ctx.Value(OriginalIPHeader).(string); ok && ogIP != "" {
req.Header.Set(OriginalIPHeader, ogIP)
}
return req
}

View file

@ -1603,4 +1603,15 @@ func TestAddAuthToken(t *testing.T) {
t.Fatalf("got '%v', expected '%v'", got, tok)
}
})
t.Run("originalIP", func(t *testing.T) {
req, err := gohttp.NewRequest("GET", "dontmatternone", strings.NewReader("this doesn't matter"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ogIP := "10.0.0.1"
pilosa.AddAuthToken(context.WithValue(context.Background(), pilosa.OriginalIPHeader, ogIP), req)
if got := req.Header.Get(pilosa.OriginalIPHeader); got != ogIP {
t.Fatalf("got '%v', expected '%v'", got, ogIP)
}
})
}

View file

@ -16,9 +16,8 @@ done
HOST=${HOSTS[2]}
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW4ifQ.I1iCgk1VU7m6e-En4ACTHIs6V2dZpy_8j2blSSo7K3U"
# ingest string key data to user index
/data/datagen --source custom --custom-config /data/tests/sup218_datagen.yaml --pilosa.index=user --pilosa.hosts=https://$HOST:10101 --pilosa.batch-size=1000 --auth-token=$ADMIN_TOKEN
/data/datagen --source custom --custom-config /data/tests/sup218_datagen.yaml --pilosa.index=user --pilosa.hosts=https://$HOST:10101 --pilosa.batch-size=1000
ifErr "running datagen on $HOST"
# install grpcurl

View file

@ -128,6 +128,7 @@ long-query-time = "10s"
secret-key = "98995f0530eeba96da1d0a04311073c0abb7b6abbfb0f5f4ef3629527ff88428"
permissions = "/etc/permissions.yml"
query-log-path = "/data/featurebase/query.log"
configured-ips = ["${DEPLOYED_INGEST_IPS}"]
EOT
echo "writing the permissions file"

View file

@ -254,6 +254,7 @@ type Auth struct {
SecretKey string `toml:"secret-key"`
PermissionsFile string `toml:"permissions"`
QueryLogPath string `toml:"query-log-path"`
ConfiguredIPs []string `toml:"configured-ips"`
}
// Namespace returns the namespace to use based on the Future flag.
@ -663,6 +664,39 @@ func (c *Config) ValidateAuth() (errors []error) {
errors = append(errors, fmt.Errorf("must provide scope for authentication with IdP - for access and refresh token"))
}
if len(c.Auth.ConfiguredIPs) > 0 {
for _, IP := range c.Auth.ConfiguredIPs {
if strings.Contains(IP, ":") {
errors = append(errors, fmt.Errorf("port is not allowed in IP %v for auth.configured-ips", IP))
continue
}
if IP == "" {
errors = append(errors, fmt.Errorf("empty string for auth.configured-ips"))
continue
}
if strings.Contains(IP, "localhost") {
errors = append(errors, fmt.Errorf("%v is not a valid IP for auth.configured-ips, DNS names are not allowed", IP))
continue
}
if strings.EqualFold(IP, "0.0.0.0") {
errors = append(errors, fmt.Errorf("%v is not a valid IP for auth.configured-ips", IP))
continue
}
// validate CIDR addresses
if strings.Contains(IP, "/") {
if _, _, err := net.ParseCIDR(IP); err != nil {
errors = append(errors, fmt.Errorf("%v is not a valid IP for auth.configured-ips: %v", IP, err))
continue
}
continue
}
// validate IP
if net.ParseIP(IP) == nil {
errors = append(errors, fmt.Errorf("%v is not a valid IP for auth.configured-ips", IP))
continue
}
}
}
return errors
}

View file

@ -281,6 +281,8 @@ func TestConfig_validateAuth(t *testing.T) {
errorMesgURL := "invalid URL"
errorMesgScope := "must provide scope"
errorMesgKey := "invalid key length"
errorMesgIP := "not a valid IP for auth.configured-ips"
errorMesgPort := "port is not allowed in IP"
validTestURL := "https://url.com/"
validClientID := "clientid"
validClientSecret := "clientSecret"
@ -290,6 +292,10 @@ func TestConfig_validateAuth(t *testing.T) {
validStringSlice := []string{"https://graph.microsoft.com/.default", "offline_access"}
validString := "asdfqwer1234asdfzxcv"
var emptySlice []string
validIPList := []string{"10.0.0.1", "10.0.0.0", "10.0.0.0/32"}
invalidIPList := []string{"0.0.0.0", "localhost", "100.0.1", "100.0.0/32"}
emptyStringIP := []string{""}
portIP := []string{"10.0.0.1:10101"}
enable := true
disable := false
@ -321,6 +327,7 @@ func TestConfig_validateAuth(t *testing.T) {
LogoutURL: emptyString,
Scopes: validStringSlice,
SecretKey: emptyString,
ConfiguredIPs: emptySlice,
},
},
{
@ -339,6 +346,7 @@ func TestConfig_validateAuth(t *testing.T) {
LogoutURL: validTestURL,
Scopes: validStringSlice,
SecretKey: validString,
ConfiguredIPs: validIPList,
},
},
{
@ -359,6 +367,7 @@ func TestConfig_validateAuth(t *testing.T) {
LogoutURL: invalidURL,
Scopes: validStringSlice,
SecretKey: validKey,
ConfiguredIPs: validIPList,
},
},
{
@ -377,6 +386,7 @@ func TestConfig_validateAuth(t *testing.T) {
LogoutURL: validTestURL,
Scopes: emptySlice,
SecretKey: validKey,
ConfiguredIPs: validIPList,
},
},
{
@ -394,6 +404,7 @@ func TestConfig_validateAuth(t *testing.T) {
Scopes: validStringSlice,
SecretKey: validKey,
QueryLogPath: "thisIsAPAth",
ConfiguredIPs: validIPList,
},
},
{
@ -410,6 +421,67 @@ func TestConfig_validateAuth(t *testing.T) {
LogoutURL: validTestURL,
Scopes: validStringSlice,
SecretKey: emptyString,
ConfiguredIPs: emptySlice,
},
},
{
// Auth enabled, all configs are set properly except configuredIPs
[]string{
errorMesgIP,
errorMesgIP,
errorMesgIP,
errorMesgIP,
},
Auth{
Enable: enable,
ClientId: validClientID,
ClientSecret: validClientSecret,
AuthorizeURL: validTestURL,
TokenURL: validTestURL,
GroupEndpointURL: validTestURL,
RedirectBaseURL: validTestURL,
LogoutURL: validTestURL,
Scopes: validStringSlice,
SecretKey: validKey,
ConfiguredIPs: invalidIPList,
},
},
{
// Auth enabled, all configs are set properly except configuredIPs
[]string{
errorMesgEmpty,
},
Auth{
Enable: enable,
ClientId: validClientID,
ClientSecret: validClientSecret,
AuthorizeURL: validTestURL,
TokenURL: validTestURL,
GroupEndpointURL: validTestURL,
RedirectBaseURL: validTestURL,
LogoutURL: validTestURL,
Scopes: validStringSlice,
SecretKey: validKey,
ConfiguredIPs: emptyStringIP,
},
},
{
// Auth enabled, all configs are set properly except configuredIPs
[]string{
errorMesgPort,
},
Auth{
Enable: enable,
ClientId: validClientID,
ClientSecret: validClientSecret,
AuthorizeURL: validTestURL,
TokenURL: validTestURL,
GroupEndpointURL: validTestURL,
RedirectBaseURL: validTestURL,
LogoutURL: validTestURL,
Scopes: validStringSlice,
SecretKey: validKey,
ConfiguredIPs: portIP,
},
},
}

View file

@ -531,7 +531,7 @@ func (m *Command) SetupServer() error {
}
ac := m.Config.Auth
m.auth, err = authn.NewAuth(m.logger, ac.RedirectBaseURL, ac.Scopes, ac.AuthorizeURL, ac.TokenURL, ac.GroupEndpointURL, ac.LogoutURL, ac.ClientId, ac.ClientSecret, ac.SecretKey)
m.auth, err = authn.NewAuth(m.logger, ac.RedirectBaseURL, ac.Scopes, ac.AuthorizeURL, ac.TokenURL, ac.GroupEndpointURL, ac.LogoutURL, ac.ClientId, ac.ClientSecret, ac.SecretKey, ac.ConfiguredIPs)
if err != nil {
return errors.Wrap(err, "instantiating authN object")
}
@ -544,6 +544,9 @@ func (m *Command) SetupServer() error {
m.queryLogger.Infof("Starting Featurebase...")
m.queryLogger.Infof("Group with admin level access: %v", p.Admin)
m.queryLogger.Infof("Permissions: %+v", p.Permissions)
if len(ac.ConfiguredIPs) > 0 {
m.queryLogger.Infof("Configured IPs for allowed networks: %v", ac.ConfiguredIPs)
}
// disable postgres binding if auth is enabled
m.Config.Postgres.Bind = ""

26
testdata/certs/localhost.crt vendored Normal file
View file

@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEcDCCAtigAwIBAgIQUcwmfB3r1kcWpRm+pJCNQTANBgkqhkiG9w0BAQsFADCB
mTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTcwNQYDVQQLDC5zb3Vo
YWlsYW5vb3JAc291aGFpbGFzLW1icC5sYW4gKFNvdWhhaWxhIE5vb3IpMT4wPAYD
VQQDDDVta2NlcnQgc291aGFpbGFub29yQHNvdWhhaWxhcy1tYnAubGFuIChTb3Vo
YWlsYSBOb29yKTAeFw0yMjAyMDMxNzAxNTRaFw0yNDA1MDMxNjAxNTRaMGIxJzAl
BgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE3MDUGA1UECwwu
c291aGFpbGFub29yQHNvdWhhaWxhcy1tYnAubGFuIChTb3VoYWlsYSBOb29yKTCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKlW+aTutSSDraStNa3T55eH
qR0F1Fxap7Zefd3TQuhNfZc7x9pyT8Rfd8n1fiL+NVAsrGWCX4kqrq5J3comPEZG
zphoWBlhko9jWWa375xTEp4Bo8lQQ1Z0GgucVk8XkmPPO7z6ZuMb2+LBtY1IkXcP
1nr0m/F1isQw2R5V51eBuNv44h0+CN+f706u5zsBrY4+wXS3l/hXzTEYAN/qryEi
xjTsvlURnZHEKNtC1QPOGJMe/Viqv3HW9vLs9r5b9+9R7mt3nSWXLHJWe5e8P3xY
s1aRWWWyd4F6q+0NYdlGN9SjN3MjaVuXCL76vvGaWmRAlYXm7y/+fIB8OIS/aV8C
AwEAAaNqMGgwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8G
A1UdIwQYMBaAFJzPC26j5NzKZj55kwlZB9ucEdk8MCAGA1UdEQQZMBeCCWxvY2Fs
aG9zdIcEAAAAAIcEwKhWLzANBgkqhkiG9w0BAQsFAAOCAYEAdTR/I1ONyQe/KKYh
R5pEWnAS7gGIW7EHkNoDhm/fv2+yplM7jpI7F6zBFIKIchSEWVSndnEerZ8lTcgG
27fuvmR4B0EpLQ3CK5jWNMMDEgSpX5pRMn6ZOuhRDglt0/+knsxra3rbUORfrl+w
FNAB7e1Q/q4eKVqxAiW0wpQEBnw3ko2/r2t1/x1diReDlb7NFztfwAxXyQKQbQvY
/V6r514cm7s4qqnEudaK8LR8G5wrTBJgRdwzkpiBsi0Kekibp2FUQcRQeUvPqgvn
c2VmJGVlWNJj5fhQO3Tn3NGLECA6jJFWVCMBHNP/DJtZjJQQhXyn4WC5r0g/aFwm
iDX2djhj43GyMT7V1fTirJpWQC4zOMIV54nZ90BULJD76+hmLrBccdZPcJHhpPw/
WGvhV+IEjxLvr/RPgcZitFwYKKEMgQcGRl6oLo71hD99cEQXhM5bgv7uvYvX4XqG
W1S7Qst6xEkZ5nLMv0dLpd7SRCUXuIyNBUMkuAW7c2NY21lC
-----END CERTIFICATE-----

28
testdata/certs/localhost.key vendored Normal file
View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCpVvmk7rUkg62k
rTWt0+eXh6kdBdRcWqe2Xn3d00LoTX2XO8fack/EX3fJ9X4i/jVQLKxlgl+JKq6u
Sd3KJjxGRs6YaFgZYZKPY1lmt++cUxKeAaPJUENWdBoLnFZPF5Jjzzu8+mbjG9vi
wbWNSJF3D9Z69JvxdYrEMNkeVedXgbjb+OIdPgjfn+9Oruc7Aa2OPsF0t5f4V80x
GADf6q8hIsY07L5VEZ2RxCjbQtUDzhiTHv1Yqr9x1vby7Pa+W/fvUe5rd50llyxy
VnuXvD98WLNWkVllsneBeqvtDWHZRjfUozdzI2lblwi++r7xmlpkQJWF5u8v/nyA
fDiEv2lfAgMBAAECggEAWxRFp5kgYqhUT9s9oOX1jUHRCqQlCRtVUzhVeGb1eJ6d
KydjIbIUBynV8xEQK+AQarPscZiCq/MCCwn9uYmBJ8dTvRN6NwSM4IRuBFpwDON9
gvCc2F1MKoa9r3sBzP5/pSFEHyuLBSHpzXekn33lh2VEVdOUwHUZEta3IKLRj/V8
eFNGLFGaRkTuVnjJpCySy89ANUxH7wXyz9g7S6WIvU0Gri6hh5JV5DZ0BYQWqrXs
obipibRiTtSjBbV3uRABlA6le8ffWR9VsiS1njNRiXDvTl/zZKQjOCQxYJ4hbKHK
BYZuz5mXxQSmZoLkFd3ttIQ98zGJGVR5HI20kt97UQKBgQDHPlB/38QUiLSRLWc0
dDziXIAlk5X0IkYDdSj0mMi5OaSOVjtCCtzQO8ncuFDJv/bjhFCrNsaLhDrSMP9w
q+X5C6IyFfZoxhFm0Yjf87szJ4Gj3LKwL33y62+4a+FoL8e2rKU2BNhC/hs+hLv+
GIg0WPUD6ApQqEzZiEy9sPwrlwKBgQDZk/TtmSO2vSsV4sUSdFYiKU/0ngsFw1yh
aAOQV5km3rqQP0bOmHvVmp8ZWWg0mRecEsih344kB1s3g60m6dfTYsfX+PCn4LoQ
bB/b3VfJmq5mHeicydI9vpggHOo4HXRvDFjuXcDuq8WCflR+RfWPDRMtr+fnOtYz
XzT/mv2JeQKBgDOUWDaicRp3wXcL7/nOVaysEaioqltHPCTNActAekYpPAZ1IGYa
dcuajsmLFa8E+R8xM8j/JysbEjcz5A0BE0oDzvt0YBQDoqGhgPOpHz9A7PjEu6WM
xehLNuLhWrskE2mhDCwN2QaOzfLDXf+Lzkbu+I8IVRNIXoy6ElPRK5cFAoGAUcy1
dapwaeB+1VCXZNPGGjvL6pyMalNDfQ8838R/OMTeASM4/K0JSYpDLGWXrsarwKqz
MWB58QOvAJUJwSqDUN6/YWwfFg6ABXKRG7kAqzPzQ0MVU/TwjPQd/1y/le9E+gb7
XEp1IYYE0IWsQHkO1ARHZJLQrfdJ1rCBnWNo1IkCgYEAtqgGtOgYb169TjftqDZD
7DdFUI6sweRfeRs91OGbLRpHU7LGia6GSm+CA8newUMJQtgT8EOR+OyDJBZTL1CS
dkk0bmW3Z+C0zBSKhNlEKE5Jg6rDJ2fpC87lWN+6ZbNOZbjiMkV4gPlVFy8fkSCn
5fSf7UT9/FFCrqKPFHPThXE=
-----END PRIVATE KEY-----