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 checkspull/5419/head
parent
9664e080df
commit
26458417c7
|
@ -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
|
||||
}
|
|
@ -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
2
go.mod
|
@ -18,7 +18,7 @@ require (
|
|||
github.com/golang/protobuf v1.3.3 // indirect
|
||||
github.com/google/go-cmp v0.3.0
|
||||
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/gorilla/websocket v1.4.1 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.1.0 // indirect
|
||||
|
|
|
@ -2,9 +2,15 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
"github.com/influxdata/chronograf/organizations"
|
||||
|
@ -30,6 +36,11 @@ func AuthorizedToken(auth oauth2.Authenticator, logger chronograf.Logger, next h
|
|||
WithField("method", r.Method).
|
||||
WithField("url", r.URL)
|
||||
|
||||
if validSignature(log, r.Header.Get("Authorization")) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
// We do not check the authorization of the principal. Those
|
||||
// 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
|
||||
// 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
|
||||
|
@ -111,6 +182,18 @@ func AuthorizedUser(
|
|||
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 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
|
||||
|
|
|
@ -2,6 +2,10 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
@ -13,6 +17,7 @@ import (
|
|||
"github.com/influxdata/chronograf/mocks"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
"github.com/influxdata/chronograf/roles"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthorizedToken(t *testing.T) {
|
||||
|
@ -63,6 +68,7 @@ func TestAuthorizedToken(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedUser(t *testing.T) {
|
||||
type fields struct {
|
||||
UsersStore chronograf.UsersStore
|
||||
|
@ -70,10 +76,11 @@ func TestAuthorizedUser(t *testing.T) {
|
|||
Logger chronograf.Logger
|
||||
}
|
||||
type args struct {
|
||||
principal *oauth2.Principal
|
||||
scheme string
|
||||
useAuth bool
|
||||
role string
|
||||
principal *oauth2.Principal
|
||||
scheme string
|
||||
useAuth bool
|
||||
role string
|
||||
authHeader string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
|
@ -85,6 +92,29 @@ func TestAuthorizedUser(t *testing.T) {
|
|||
hasServerContext 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",
|
||||
fields: fields{
|
||||
|
@ -1800,6 +1830,9 @@ func TestAuthorizedUser(t *testing.T) {
|
|||
"http://any.url", // can be any valid URL as we are bypassing mux
|
||||
nil,
|
||||
)
|
||||
if tt.args.authHeader != "" {
|
||||
r.Header.Set("Authorization", tt.args.authHeader)
|
||||
}
|
||||
if tt.args.principal == nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, nil))
|
||||
} else {
|
||||
|
@ -1945,7 +1978,28 @@ func TestRawStoreAccess(t *testing.T) {
|
|||
if 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)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
|
||||
|
@ -36,6 +37,7 @@ type MuxOpts struct {
|
|||
CustomLinks []CustomLink // Any custom external links for client's User menu
|
||||
PprofEnabled bool // Mount pprof routes for profiling
|
||||
DisableGZip bool // Optionally disable gzip.
|
||||
nonceExpire time.Duration
|
||||
}
|
||||
|
||||
// NewMux attaches all the route handlers; handler returned servers chronograf.
|
||||
|
@ -150,6 +152,9 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
|
|||
/* Health */
|
||||
router.GET("/ping", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
/* Auth */
|
||||
router.GET("/nonce", nonce(opts.nonceExpire))
|
||||
|
||||
/* API */
|
||||
// Organizations
|
||||
router.GET("/chronograf/v1/organizations", EnsureAdmin(service.Organizations))
|
||||
|
|
|
@ -2,8 +2,13 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/rand"
|
||||
"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"`
|
||||
|
||||
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"`
|
||||
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"`
|
||||
|
@ -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
|
||||
func (s *Server) Serve(ctx context.Context) {
|
||||
go rotateSuperAdminNonce(ctx, s.NonceExpiration)
|
||||
|
||||
logger := clog.New(clog.ParseLevel(s.LogLevel))
|
||||
customLinks, err := NewCustomLinks(s.CustomLinks)
|
||||
if err != nil {
|
||||
|
@ -347,6 +388,12 @@ func (s *Server) Serve(ctx context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
err = s.setPubkey()
|
||||
if err != nil {
|
||||
logger.Error("Unable to set public key ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var db kv.Store
|
||||
if len(s.EtcdEndpoints) == 0 {
|
||||
db, err = bolt.NewClient(ctx,
|
||||
|
@ -412,6 +459,7 @@ func (s *Server) Serve(ctx context.Context) {
|
|||
CustomLinks: customLinks,
|
||||
PprofEnabled: s.PprofEnabled,
|
||||
DisableGZip: s.DisableGZip,
|
||||
nonceExpire: s.NonceExpiration,
|
||||
}, service)
|
||||
|
||||
// Add chronograf's version header to all requests
|
||||
|
|
Loading…
Reference in New Issue