feat: add ability to authenticate single superadmin user (#5400)

* feat: add ability to authenticate single superadmin user

This short-circuits any configured authentication, allowing a user
with the correctly signed message to act against the api.

* Present an expiring message to be signed/verified

* Add chronoctl command to ouput token for use in auth header

* Add command to generate and store RSA keypair

* Test new superadmin token checks
pull/5419/head
Greg 2020-02-28 14:14:46 -07:00 committed by GitHub
parent 9664e080df
commit 26458417c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 343 additions and 6 deletions

56
cmd/chronoctl/genKey.go Normal file
View File

@ -0,0 +1,56 @@
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io/ioutil"
"os"
flags "github.com/jessevdk/go-flags"
)
func init() {
parser.AddCommand("gen-keypair",
"Generate RSA keypair.",
"Generate RSA keypair and write to filesystem.",
&genKeyCommand{})
}
type genKeyCommand struct {
Out flags.Filename `long:"out" description:"File to save keys to. The public key is stored in a file with the same name with \".pub\" appended." default:"chronograf-rsa"`
Bits int `long:"bits" description:"Generate RSA keypair with the specified number of bits." default:"4096"`
}
func (t *genKeyCommand) Execute(args []string) error {
_, err := os.Stat(string(t.Out))
if err == nil {
errExit(errors.New("Specify non-existant file to write to."))
}
_, err = os.Stat(string(t.Out) + ".pub")
if err == nil {
errExit(errors.New("Specify non-existant file.pub to write to."))
}
key, err := rsa.GenerateKey(rand.Reader, t.Bits)
if err != nil {
errExit(err)
}
ioutil.WriteFile(string(t.Out), pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}), 0600)
ioutil.WriteFile(string(t.Out)+".pub", pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: x509.MarshalPKCS1PublicKey(key.Public().(*rsa.PublicKey)),
}), 0644)
fmt.Printf("Key pair generated and saved at %s and %s.pub\n", t.Out, t.Out)
return nil
}

91
cmd/chronoctl/token.go Normal file
View File

@ -0,0 +1,91 @@
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io/ioutil"
"net/http"
flags "github.com/jessevdk/go-flags"
)
func init() {
parser.AddCommand("token",
"Get current token for superadmin user (if configured)",
"Token gets and signs the nonce, providing an expiring token to use in the header: 'Authorization: CHRONOGRAF-SHA256 xxx'",
&tokenCommand{})
}
type tokenCommand struct {
ChronoURL string `long:"chronograf-url" default:"http://localhost:8888" description:"Chronograf's URL." env:"CHRONOGRAF_URL"`
PrivKeyFile flags.Filename `long:"priv-key-file" description:"File location of private key for superadmin token authentication." env:"PRIV_KEY_FILE"`
}
func (t *tokenCommand) Execute(args []string) error {
key, err := parsePrivKey(string(t.PrivKeyFile))
if err != nil {
errExit(fmt.Errorf("Failed to parse RSA key: %s", err.Error()))
}
msg, err := getNonceMsg(t.ChronoURL)
if err != nil {
errExit(err)
}
dgst, err := signMsg(msg, key)
if err != nil {
errExit(fmt.Errorf("Failed to sign: %s", err.Error()))
}
fmt.Println(base64.StdEncoding.EncodeToString(dgst))
return nil
}
func parsePrivKey(privKeyFile string) (*rsa.PrivateKey, error) {
if privKeyFile == "" {
return nil, errors.New("No private key file specified")
}
pemBytes, err := ioutil.ReadFile(string(privKeyFile))
if err != nil {
return nil, fmt.Errorf("Failed to read file: %s", err.Error())
}
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("No PEM formatted key found")
} else if block.Type != "RSA PRIVATE KEY" {
return nil, fmt.Errorf("Unsupported key type %q", block.Type)
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}
func getNonceMsg(url string) ([]byte, error) {
req, err := http.NewRequest("GET", url+"/nonce", nil)
if err != nil {
return nil, fmt.Errorf("Failed to create request: %s", err.Error())
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Failed to get nonce: %s", err.Error())
}
defer resp.Body.Close()
return ioutil.ReadAll(resp.Body)
}
func signMsg(msg []byte, key *rsa.PrivateKey) ([]byte, error) {
h := crypto.SHA256.New()
h.Write(msg)
d := h.Sum(nil)
return rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, d)
}

2
go.mod
View File

@ -18,7 +18,7 @@ require (
github.com/golang/protobuf v1.3.3 // indirect github.com/golang/protobuf v1.3.3 // indirect
github.com/google/go-cmp v0.3.0 github.com/google/go-cmp v0.3.0
github.com/google/go-github v17.0.0+incompatible github.com/google/go-github v17.0.0+incompatible
github.com/google/uuid v1.1.1 // indirect github.com/google/uuid v1.1.1
github.com/goreleaser/goreleaser v0.97.0 // indirect github.com/goreleaser/goreleaser v0.97.0 // indirect
github.com/gorilla/websocket v1.4.1 // indirect github.com/gorilla/websocket v1.4.1 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.1.0 // indirect

View File

@ -2,9 +2,15 @@ package server
import ( import (
"context" "context"
"crypto"
"crypto/rsa"
"encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/influxdata/chronograf" "github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/oauth2" "github.com/influxdata/chronograf/oauth2"
"github.com/influxdata/chronograf/organizations" "github.com/influxdata/chronograf/organizations"
@ -30,6 +36,11 @@ func AuthorizedToken(auth oauth2.Authenticator, logger chronograf.Logger, next h
WithField("method", r.Method). WithField("method", r.Method).
WithField("url", r.URL) WithField("url", r.URL)
if validSignature(log, r.Header.Get("Authorization")) {
next.ServeHTTP(w, r)
return
}
ctx := r.Context() ctx := r.Context()
// We do not check the authorization of the principal. Those // We do not check the authorization of the principal. Those
// served further down the chain should do so. // served further down the chain should do so.
@ -83,6 +94,66 @@ func RawStoreAccess(logger chronograf.Logger, next http.HandlerFunc) http.Handle
} }
} }
// nonce returns an nonce message to be signed.
func nonce(expires time.Duration) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Expires", msgLastSet.Add(expires).Format(time.RFC1123))
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(signerMessage))
}
}
var (
signerMessage = uuid.New().String() // signerMessage is the message to sign with the superadmin user's private key.
msgLastSet = time.Now()
)
func rotateSuperAdminNonce(ctx context.Context, expires time.Duration) {
tick := time.NewTicker(expires)
defer tick.Stop()
for {
select {
case <-tick.C:
msgLastSet = time.Now()
signerMessage = uuid.New().String()
case <-ctx.Done():
return
}
}
}
// validSignature validates the message was signed with the private key corresponding
// to the public key given to chronograf on start. Ideally, we would provide the
// message to be signed to the user in another call. This would allow old signature/msg
// pairs to be "expired".
func validSignature(log chronograf.Logger, authHeader string) bool {
if publicKey == nil || authHeader == "" {
return false
}
sig := strings.TrimSpace(strings.TrimPrefix(authHeader, "CHRONOGRAF-SHA256"))
h := crypto.SHA256.New()
h.Write([]byte(signerMessage))
d := h.Sum(nil)
data, err := base64.StdEncoding.DecodeString(sig)
if err != nil {
log.Debug("Failed to base64 decode signature")
return false
}
err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, d, data)
if err != nil {
log.Debug("Failed to verify signature: ", err)
return false
}
return true
}
// AuthorizedUser extracts the user name and provider from context. If the // AuthorizedUser extracts the user name and provider from context. If the
// user and provider can be found on the context, we look up the user by their // user and provider can be found on the context, we look up the user by their
// name and provider. If the user is found, we verify that the user has at at // name and provider. If the user is found, we verify that the user has at at
@ -111,6 +182,18 @@ func AuthorizedUser(
return return
} }
if validSignature(log, r.Header.Get("Authorization")) {
// If there is super admin auth, then set the organization id to be the deault org id on context
// so that calls like hasOrganizationContext as used in Organization Config service
// method OrganizationConfig can successfully get the organization id
ctx = context.WithValue(ctx, organizations.ContextKey, defaultOrg.ID)
// And if there is super admin auth, then give the user raw access to the DataStore
r = r.WithContext(serverContext(ctx))
next(w, r)
return
}
if !useAuth { if !useAuth {
// If there is no auth, then set the organization id to be the default org id on context // If there is no auth, then set the organization id to be the default org id on context
// so that calls like hasOrganizationContext as used in Organization Config service // so that calls like hasOrganizationContext as used in Organization Config service

View File

@ -2,6 +2,10 @@ package server
import ( import (
"context" "context"
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -13,6 +17,7 @@ import (
"github.com/influxdata/chronograf/mocks" "github.com/influxdata/chronograf/mocks"
"github.com/influxdata/chronograf/oauth2" "github.com/influxdata/chronograf/oauth2"
"github.com/influxdata/chronograf/roles" "github.com/influxdata/chronograf/roles"
"github.com/stretchr/testify/require"
) )
func TestAuthorizedToken(t *testing.T) { func TestAuthorizedToken(t *testing.T) {
@ -63,6 +68,7 @@ func TestAuthorizedToken(t *testing.T) {
} }
} }
} }
func TestAuthorizedUser(t *testing.T) { func TestAuthorizedUser(t *testing.T) {
type fields struct { type fields struct {
UsersStore chronograf.UsersStore UsersStore chronograf.UsersStore
@ -70,10 +76,11 @@ func TestAuthorizedUser(t *testing.T) {
Logger chronograf.Logger Logger chronograf.Logger
} }
type args struct { type args struct {
principal *oauth2.Principal principal *oauth2.Principal
scheme string scheme string
useAuth bool useAuth bool
role string role string
authHeader string
} }
tests := []struct { tests := []struct {
name string name string
@ -85,6 +92,29 @@ func TestAuthorizedUser(t *testing.T) {
hasServerContext bool hasServerContext bool
authorized bool authorized bool
}{ }{
{
name: "Use superadmin token",
fields: fields{
UsersStore: &mocks.UsersStore{},
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
}, nil
},
},
Logger: clog.New(clog.DebugLevel),
},
args: args{
useAuth: true,
authHeader: genToken(t),
},
hasOrganizationContext: true,
hasSuperAdminContext: false,
hasRoleContext: false,
hasServerContext: true,
authorized: true,
},
{ {
name: "Not using auth", name: "Not using auth",
fields: fields{ fields: fields{
@ -1800,6 +1830,9 @@ func TestAuthorizedUser(t *testing.T) {
"http://any.url", // can be any valid URL as we are bypassing mux "http://any.url", // can be any valid URL as we are bypassing mux
nil, nil,
) )
if tt.args.authHeader != "" {
r.Header.Set("Authorization", tt.args.authHeader)
}
if tt.args.principal == nil { if tt.args.principal == nil {
r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, nil)) r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, nil))
} else { } else {
@ -1945,7 +1978,28 @@ func TestRawStoreAccess(t *testing.T) {
if hasServerCtx != tt.wants.hasServerContext { if hasServerCtx != tt.wants.hasServerContext {
t.Errorf("%q. RawStoreAccess().Context().Server = %v, expected %v", tt.name, hasServerCtx, tt.wants.hasServerContext) t.Errorf("%q. RawStoreAccess().Context().Server = %v, expected %v", tt.name, hasServerCtx, tt.wants.hasServerContext)
} }
}) })
} }
} }
func TestValidSignature(t *testing.T) {
require.True(t, validSignature(mocks.NewLogger(), genToken(t)))
}
func genToken(t *testing.T) string {
key, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
signerMessage = "abc123"
sha256 := crypto.SHA256
h := sha256.New()
h.Write([]byte(signerMessage))
d := h.Sum(nil)
x, err := rsa.SignPKCS1v15(rand.Reader, key, sha256, d)
require.NoError(t, err)
publicKey = key.Public().(*rsa.PublicKey)
return base64.StdEncoding.EncodeToString(x)
}

View File

@ -8,6 +8,7 @@ import (
"path" "path"
"strconv" "strconv"
"strings" "strings"
"time"
_ "net/http/pprof" _ "net/http/pprof"
@ -36,6 +37,7 @@ type MuxOpts struct {
CustomLinks []CustomLink // Any custom external links for client's User menu CustomLinks []CustomLink // Any custom external links for client's User menu
PprofEnabled bool // Mount pprof routes for profiling PprofEnabled bool // Mount pprof routes for profiling
DisableGZip bool // Optionally disable gzip. DisableGZip bool // Optionally disable gzip.
nonceExpire time.Duration
} }
// NewMux attaches all the route handlers; handler returned servers chronograf. // NewMux attaches all the route handlers; handler returned servers chronograf.
@ -150,6 +152,9 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
/* Health */ /* Health */
router.GET("/ping", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) router.GET("/ping", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
/* Auth */
router.GET("/nonce", nonce(opts.nonceExpire))
/* API */ /* API */
// Organizations // Organizations
router.GET("/chronograf/v1/organizations", EnsureAdmin(service.Organizations)) router.GET("/chronograf/v1/organizations", EnsureAdmin(service.Organizations))

View File

@ -2,8 +2,13 @@ package server
import ( import (
"context" "context"
"crypto/rsa"
"crypto/tls" "crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt" "fmt"
"io/ioutil"
"log" "log"
"math/rand" "math/rand"
"net" "net"
@ -103,6 +108,10 @@ type Server struct {
RedirAuth string `long:"redir-auth-login" description:"Automatically redirect login to specified OAuth provider." env:"REDIR_AUTH_LOGIN"` RedirAuth string `long:"redir-auth-login" description:"Automatically redirect login to specified OAuth provider." env:"REDIR_AUTH_LOGIN"`
PubKey string `long:"pub-key" description:"Public key or superadmin token authentication" env:"PUB_KEY"`
PubKeyFile flags.Filename `long:"pub-key-file" description:"File location of public key for superadmin token authentication." env:"PUB_KEY_FILE"`
NonceExpiration time.Duration `long:"nonce-expiration" default:"10m" description:"Duration in which a signed nonce is valid. Used for superadmin token authentication." env:"NONCE_EXPIRATION"`
StatusFeedURL string `long:"status-feed-url" description:"URL of a JSON Feed to display as a News Feed on the client Status page." default:"https://influxdata.com/feed/json" env:"STATUS_FEED_URL"` StatusFeedURL string `long:"status-feed-url" description:"URL of a JSON Feed to display as a News Feed on the client Status page." default:"https://influxdata.com/feed/json" env:"STATUS_FEED_URL"`
CustomLinks map[string]string `long:"custom-link" description:"Custom link to be added to the client User menu. Multiple links can be added by using multiple of the same flag with different 'name:url' values, or as an environment variable with comma-separated 'name:url' values. E.g. via flags: '--custom-link=InfluxData:https://www.influxdata.com --custom-link=Chronograf:https://github.com/influxdata/chronograf'. E.g. via environment variable: 'export CUSTOM_LINKS=InfluxData:https://www.influxdata.com,Chronograf:https://github.com/influxdata/chronograf'" env:"CUSTOM_LINKS" env-delim:","` CustomLinks map[string]string `long:"custom-link" description:"Custom link to be added to the client User menu. Multiple links can be added by using multiple of the same flag with different 'name:url' values, or as an environment variable with comma-separated 'name:url' values. E.g. via flags: '--custom-link=InfluxData:https://www.influxdata.com --custom-link=Chronograf:https://github.com/influxdata/chronograf'. E.g. via environment variable: 'export CUSTOM_LINKS=InfluxData:https://www.influxdata.com,Chronograf:https://github.com/influxdata/chronograf'" env:"CUSTOM_LINKS" env-delim:","`
TelegrafSystemInterval time.Duration `long:"telegraf-system-interval" default:"1m" description:"Duration used in the GROUP BY time interval for the hosts list" env:"TELEGRAF_SYSTEM_INTERVAL"` TelegrafSystemInterval time.Duration `long:"telegraf-system-interval" default:"1m" description:"Duration used in the GROUP BY time interval for the hosts list" env:"TELEGRAF_SYSTEM_INTERVAL"`
@ -335,8 +344,40 @@ func (s *Server) newBuilders(logger chronograf.Logger) builders {
} }
} }
var publicKey *rsa.PublicKey // pubKey is for the simple super admin jwt-esque check.
// Set the public key preferring from file, if set.
func (s *Server) setPubkey() error {
pubKey := []byte(s.PubKey)
if fil := s.PubKeyFile; fil != "" {
key, err := ioutil.ReadFile(string(s.PubKeyFile))
if err != nil {
return err
}
pubKey = key
}
if len(pubKey) == 0 {
return nil
}
block, _ := pem.Decode(pubKey)
if block == nil {
return errors.New("no key found")
} else if block.Type != "PUBLIC KEY" {
return fmt.Errorf("unsupported key type %q", block.Type)
}
var err error
publicKey, err = x509.ParsePKCS1PublicKey(block.Bytes)
return err
}
// Serve starts and runs the chronograf server // Serve starts and runs the chronograf server
func (s *Server) Serve(ctx context.Context) { func (s *Server) Serve(ctx context.Context) {
go rotateSuperAdminNonce(ctx, s.NonceExpiration)
logger := clog.New(clog.ParseLevel(s.LogLevel)) logger := clog.New(clog.ParseLevel(s.LogLevel))
customLinks, err := NewCustomLinks(s.CustomLinks) customLinks, err := NewCustomLinks(s.CustomLinks)
if err != nil { if err != nil {
@ -347,6 +388,12 @@ func (s *Server) Serve(ctx context.Context) {
return return
} }
err = s.setPubkey()
if err != nil {
logger.Error("Unable to set public key ", err)
os.Exit(1)
}
var db kv.Store var db kv.Store
if len(s.EtcdEndpoints) == 0 { if len(s.EtcdEndpoints) == 0 {
db, err = bolt.NewClient(ctx, db, err = bolt.NewClient(ctx,
@ -412,6 +459,7 @@ func (s *Server) Serve(ctx context.Context) {
CustomLinks: customLinks, CustomLinks: customLinks,
PprofEnabled: s.PprofEnabled, PprofEnabled: s.PprofEnabled,
DisableGZip: s.DisableGZip, DisableGZip: s.DisableGZip,
nonceExpire: s.NonceExpiration,
}, service) }, service)
// Add chronograf's version header to all requests // Add chronograf's version header to all requests