Repository: nats-io/nats-server
Pull request #2461 (2021-08-23 17:45:52)

[added] operator option to ensure user are signed by certain accounts
option name: resolver_pinned_accounts
Contains a list of public account nkeys.
Connecting user of leaf nodes need to be signed by this.
The system account will always be able to connect.

Signed-off-by: Matthias Hanel <mh@synadia.com>



Patch:
diff --git a/server/auth.go b/server/auth.go
index d6e7fbfe..3ee51576 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -25,6 +25,7 @@ import (
 	"net/url"
 	"regexp"
 	"strings"
+	"sync/atomic"
 	"time"
 
 	"github.com/nats-io/jwt/v2"
@@ -393,10 +394,11 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 		return true
 	}
 	var (
-		username   string
-		password   string
-		token      string
-		noAuthUser string
+		username      string
+		password      string
+		token         string
+		noAuthUser    string
+		pinnedAcounts map[string]struct{}
 	)
 	tlsMap := opts.TLSMap
 	if c.kind == CLIENT {
@@ -441,7 +443,7 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 
 	// Check if we have trustedKeys defined in the server. If so we require a user jwt.
 	if s.trustedKeys != nil {
-		if c.opts.JWT == "" {
+		if c.opts.JWT == _EMPTY_ {
 			s.mu.Unlock()
 			c.Debugf("Authentication requires a user JWT")
 			return false
@@ -460,12 +462,13 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 			c.Debugf("User JWT no longer valid: %+v", vr)
 			return false
 		}
+		pinnedAcounts = opts.resolverPinnedAccounts
 	}
 
 	// Check if we have nkeys or users for client.
 	hasNkeys := len(s.nkeys) > 0
 	hasUsers := len(s.users) > 0
-	if hasNkeys && c.opts.Nkey != "" {
+	if hasNkeys && c.opts.Nkey != _EMPTY_ {
 		nkey, ok = s.nkeys[c.opts.Nkey]
 		if !ok || !c.connectionTypeAllowed(nkey.AllowedConnectionTypes) {
 			s.mu.Unlock()
@@ -477,17 +480,17 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 			authorized := checkClientTLSCertSubject(c, func(u string, certDN *ldap.DN, _ bool) (string, bool) {
 				// First do literal lookup using the resulting string representation
 				// of RDNSequence as implemented by the pkix package from Go.
-				if u != "" {
+				if u != _EMPTY_ {
 					usr, ok := s.users[u]
 					if !ok || !c.connectionTypeAllowed(usr.AllowedConnectionTypes) {
-						return "", ok
+						return _EMPTY_, ok
 					}
 					user = usr
 					return usr.Username, ok
 				}
 
 				if certDN == nil {
-					return "", false
+					return _EMPTY_, false
 				}
 
 				// Look through the accounts for a DN that is equal to the one
@@ -520,13 +523,13 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 						return usr.Username, true
 					}
 				}
-				return "", false
+				return _EMPTY_, false
 			})
 			if !authorized {
 				s.mu.Unlock()
 				return false
 			}
-			if c.opts.Username != "" {
+			if c.opts.Username != _EMPTY_ {
 				s.Warnf("User %q found in connect proto, but user required from cert", c.opts.Username)
 			}
 			// Already checked that the client didn't send a user in connect
@@ -584,9 +587,16 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 			return false
 		}
 		issuer := juc.Issuer
-		if juc.IssuerAccount != "" {
+		if juc.IssuerAccount != _EMPTY_ {
 			issuer = juc.IssuerAccount
 		}
+		if pinnedAcounts != nil {
+			if _, ok := pinnedAcounts[issuer]; !ok {
+				c.Debugf("Account %s not listed as operator pinned account", issuer)
+				atomic.AddUint64(&s.pinnedAccFail, 1)
+				return false
+			}
+		}
 		if acc, err = s.LookupAccount(issuer); acc == nil {
 			c.Debugf("Account JWT lookup error: %v", err)
 			return false
@@ -617,7 +627,7 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 		// FIXME: if BearerToken is only for WSS, need check for server with that port enabled
 		if !juc.BearerToken {
 			// Verify the signature against the nonce.
-			if c.opts.Sig == "" {
+			if c.opts.Sig == _EMPTY_ {
 				c.Debugf("Signature missing")
 				return false
 			}
@@ -677,7 +687,7 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 	}
 
 	if nkey != nil {
-		if c.opts.Sig == "" {
+		if c.opts.Sig == _EMPTY_ {
 			c.Debugf("Signature missing")
 			return false
 		}
@@ -715,9 +725,9 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 	}
 
 	if c.kind == CLIENT {
-		if token != "" {
+		if token != _EMPTY_ {
 			return comparePasswords(token, c.opts.Token)
-		} else if username != "" {
+		} else if username != _EMPTY_ {
 			if username != c.opts.Username {
 				return false
 			}
diff --git a/server/jwt.go b/server/jwt.go
index 478cf4f5..e7a5babb 100644
--- a/server/jwt.go
+++ b/server/jwt.go
@@ -144,6 +144,17 @@ func validateTrustedOperators(o *Options) error {
 			return fmt.Errorf("trusted Keys %q are required to be a valid public operator nkey", key)
 		}
 	}
+	if len(o.resolverPinnedAccounts) > 0 {
+		for key := range o.resolverPinnedAccounts {
+			if !nkeys.IsValidPublicAccountKey(key) {
+				return fmt.Errorf("pinned account key %q is not a valid public account nkey", key)
+			}
+		}
+		// ensure the system account (belonging to the operator can always connect)
+		if o.SystemAccount != _EMPTY_ {
+			o.resolverPinnedAccounts[o.SystemAccount] = struct{}{}
+		}
+	}
 	return nil
 }
 
diff --git a/server/monitor.go b/server/monitor.go
index 7626764f..15ec0a84 100644
--- a/server/monitor.go
+++ b/server/monitor.go
@@ -1119,6 +1119,7 @@ type Varz struct {
 	TrustedOperatorsJwt   []string              `json:"trusted_operators_jwt,omitempty"`
 	TrustedOperatorsClaim []*jwt.OperatorClaims `json:"trusted_operators_claim,omitempty"`
 	SystemAccount         string                `json:"system_account,omitempty"`
+	PinnedAccountFail     uint64                `json:"pinned_account_fails,omitempty"`
 }
 
 // JetStreamVarz contains basic runtime information about jetstream
@@ -1455,6 +1456,7 @@ func (s *Server) updateVarzRuntimeFields(v *Varz, forceUpdate bool, pcpu float64
 	v.OutMsgs = atomic.LoadInt64(&s.outMsgs)
 	v.OutBytes = atomic.LoadInt64(&s.outBytes)
 	v.SlowConsumers = atomic.LoadInt64(&s.slowConsumers)
+	v.PinnedAccountFail = atomic.LoadUint64(&s.pinnedAccFail)
 
 	// Make sure to reset in case we are re-using.
 	v.Subscriptions = 0
diff --git a/server/opts.go b/server/opts.go
index 471ba675..15ffd176 100644
--- a/server/opts.go
+++ b/server/opts.go
@@ -290,8 +290,9 @@ type Options struct {
 	inCmdLine map[string]bool
 
 	// private fields for operator mode
-	operatorJWT      []string
-	resolverPreloads map[string]string
+	operatorJWT            []string
+	resolverPreloads       map[string]string
+	resolverPinnedAccounts map[string]struct{}
 
 	// private fields, used for testing
 	gatewaysSolicitDelay time.Duration
@@ -1120,8 +1121,7 @@ func (o *Options) processConfigFileLine(k string, v interface{}, errors *[]error
 		for key, val := range mp {
 			tk, val = unwrapValue(val, &lt)
 			if jwtstr, ok := val.(string); !ok {
-				err := &configErr{tk, "preload map value should be a string JWT"}
-				*errors = append(*errors, err)
+				*errors = append(*errors, &configErr{tk, "preload map value should be a string JWT"})
 				continue
 			} else {
 				// Make sure this is a valid account JWT, that is a config error.
@@ -1134,6 +1134,33 @@ func (o *Options) processConfigFileLine(k string, v interface{}, errors *[]error
 				o.resolverPreloads[key] = jwtstr
 			}
 		}
+	case "resolver_pinned_accounts":
+		switch v := v.(type) {
+		case string:
+			o.resolverPinnedAccounts = map[string]struct{}{v: {}}
+		case []string:
+			o.resolverPinnedAccounts = make(map[string]struct{})
+			for _, mv := range v {
+				o.resolverPinnedAccounts[mv] = struct{}{}
+			}
+		case []interface{}:
+			o.resolverPinnedAccounts = make(map[string]struct{})
+			for _, mv := range v {
+				tk, mv = unwrapValue(mv, &lt)
+				if key, ok := mv.(string); ok {
+					o.resolverPinnedAccounts[key] = struct{}{}
+				} else {
+					err := &configErr{tk,
+						fmt.Sprintf("error parsing resolver_pinned_accounts: unsupported type in array %T", mv)}
+					*errors = append(*errors, err)
+					continue
+				}
+			}
+		default:
+			err := &configErr{tk, fmt.Sprintf("error parsing resolver_pinned_accounts: unsupported type %T", v)}
+			*errors = append(*errors, err)
+			return
+		}
 	case "no_auth_user":
 		o.NoAuthUser = v.(string)
 	case "system_account", "system":
diff --git a/server/server.go b/server/server.go
index d5c8952f..56c38d82 100644
--- a/server/server.go
+++ b/server/server.go
@@ -262,6 +262,9 @@ type Server struct {
 	// the server will create a fake user and add it to the list of users.
 	// Keep track of what that user name is for config reload purposes.
 	sysAccOnlyNoAuthUser string
+
+	// How often user logon fails due to the issuer account not being pinned.
+	pinnedAccFail uint64
 }
 
 // For tracking JS nodes.


Test patch:
diff --git a/server/jwt_test.go b/server/jwt_test.go
index 2c8d7c49..ff8eb267 100644
--- a/server/jwt_test.go
+++ b/server/jwt_test.go
@@ -5742,6 +5742,70 @@ func TestJWTMappings(t *testing.T) {
 	test("foo2", "bar2", true)
 }
 
+func TestJWTOperatorPinnedAccounts(t *testing.T) {
+	kps, pubs, jwts := [4]nkeys.KeyPair{}, [4]string{}, [4]string{}
+	for i := 0; i < 4; i++ {
+		kps[i], pubs[i] = createKey(t)
+		jwts[i] = encodeClaim(t, jwt.NewAccountClaims(pubs[i]), pubs[i])
+	}
+	sysCreds := newUser(t, kps[0]) // index 0 is handled as system account
+	defer removeFile(t, sysCreds)
+
+	dirSrv := createDir(t, "srv")
+	defer removeDir(t, dirSrv)
+
+	cfgCommon := fmt.Sprintf(`
+		listen: -1
+		operator: %s
+		system_account: %s
+		resolver: MEM
+		resolver_preload: {
+			%s:%s
+			%s:%s
+			%s:%s
+			%s:%s
+		}`, ojwt, pubs[0], pubs[0], jwts[0], pubs[1], jwts[1], pubs[2], jwts[2], pubs[3], jwts[3])
+	cfgFmt := cfgCommon + `
+		resolver_pinned_accounts: [%s, %s]
+	`
+	conf := createConfFile(t, []byte(fmt.Sprintf(cfgFmt, pubs[1], pubs[2])))
+	defer removeFile(t, conf)
+	srv, _ := RunServerWithConfig(conf)
+	defer srv.Shutdown()
+
+	connectPass := func(keys ...nkeys.KeyPair) {
+		for _, kp := range keys {
+			nc, err := nats.Connect(srv.ClientURL(), createUserCreds(t, srv, kp))
+			require_NoError(t, err)
+			defer nc.Close()
+		}
+	}
+	var pinnedFail uint64
+	connectFail := func(key nkeys.KeyPair) {
+		_, err := nats.Connect(srv.ClientURL(), createUserCreds(t, srv, key))
+		require_Error(t, err)
+		require_Contains(t, err.Error(), "Authorization Violation")
+		v, err := srv.Varz(&VarzOptions{})
+		require_NoError(t, err)
+		require_True(t, pinnedFail+1 == v.PinnedAccountFail)
+		pinnedFail = v.PinnedAccountFail
+	}
+
+	connectPass(kps[0], kps[1], kps[2]) // make sure user from accounts listed and system account (index 0) work
+	connectFail(kps[3])                 // make sure the other user does not work
+	// reload and test again
+	reloadUpdateConfig(t, srv, conf, fmt.Sprintf(cfgFmt, pubs[2], pubs[3]))
+	connectPass(kps[0], kps[2], kps[3]) // make sure user from accounts listed and system account (index 0) work
+	connectFail(kps[1])                 // make sure the other user does not work
+	// completely disable and test again
+	reloadUpdateConfig(t, srv, conf, cfgCommon)
+	connectPass(kps[0], kps[1], kps[2], kps[3]) // make sure every account and system account (index 0) can connect
+	// re-enable and test again
+	reloadUpdateConfig(t, srv, conf, fmt.Sprintf(cfgFmt, pubs[2], pubs[3]))
+	connectPass(kps[0], kps[2], kps[3]) // make sure user from accounts listed and system account (index 0) work
+	connectFail(kps[1])                 // make sure the other user does not work
+}
+
 func TestJWTNoSystemAccountButNatsResolver(t *testing.T) {
 	dirSrv := createDir(t, "srv")
 	defer removeDir(t, dirSrv)
diff --git a/server/opts_test.go b/server/opts_test.go
index b42f999a..6955fe64 100644
--- a/server/opts_test.go
+++ b/server/opts_test.go
@@ -3200,3 +3200,26 @@ func TestQueuePermissions(t *testing.T) {
 
 	}
 }
+
+func TestResolverPinnedAccountsFail(t *testing.T) {
+	cfgFmt := `
+		operator: %s
+		resolver: URL(foo.bar)
+		resolver_pinned_accounts: [%s]
+	`
+	dirSrv := createDir(t, "srv")
+	defer removeDir(t, dirSrv)
+
+	conf := createConfFile(t, []byte(fmt.Sprintf(cfgFmt, ojwt, "f")))
+	defer removeFile(t, conf)
+	srv, err := NewServer(LoadConfig(conf))
+	defer srv.Shutdown()
+	require_Error(t, err)
+	require_Contains(t, err.Error(), " is not a valid public account nkey")
+
+	conf = createConfFile(t, []byte(fmt.Sprintf(cfgFmt, ojwt, "1, x")))
+	defer removeFile(t, conf)
+	_, err = ProcessConfigFile(conf)
+	require_Error(t, err)
+	require_Contains(t, "parsing resolver_pinned_accounts: unsupported type")
+}