diff --git a/client/v3/client.go b/client/v3/client.go index 8789acb38c8..8402ce9f07a 100644 --- a/client/v3/client.go +++ b/client/v3/client.go @@ -69,7 +69,10 @@ type Client struct { // Username is a user name for authentication. Username string // Password is a password for authentication. - Password string + Password string + // Token is a JWT used for authentication instead of a password. + Token string + authTokenBundle credentials.PerRPCCredentialsBundle callOpts []grpc.CallOption @@ -288,6 +291,11 @@ func (c *Client) Dial(ep string) (*grpc.ClientConn, error) { func (c *Client) getToken(ctx context.Context) error { var err error // return last error in a case of fail + if c.Token != "" { + c.authTokenBundle.UpdateAuthToken(c.Token) + return nil + } + if c.Username == "" || c.Password == "" { return nil } @@ -376,6 +384,10 @@ func newClient(cfg *Config) (*Client, error) { creds = credentials.NewTransportCredential(cfg.TLS) } + if cfg.Token != "" && (cfg.Username != "" || cfg.Password != "") { + return nil, errors.New("Username/Password and Token configurations are mutually exclusive") + } + // use a temporary skeleton client to bootstrap first connection baseCtx := context.TODO() if cfg.Context != nil { @@ -414,6 +426,12 @@ func newClient(cfg *Config) (*Client, error) { client.Password = cfg.Password client.authTokenBundle = credentials.NewPerRPCCredentialBundle() } + + if cfg.Token != "" { + client.Token = cfg.Token + client.authTokenBundle = credentials.NewPerRPCCredentialBundle() + } + if cfg.MaxCallSendMsgSize > 0 || cfg.MaxCallRecvMsgSize > 0 { if cfg.MaxCallRecvMsgSize > 0 && cfg.MaxCallSendMsgSize > cfg.MaxCallRecvMsgSize { return nil, fmt.Errorf("gRPC message recv limit (%d bytes) must be greater than send limit (%d bytes)", cfg.MaxCallRecvMsgSize, cfg.MaxCallSendMsgSize) diff --git a/client/v3/client_test.go b/client/v3/client_test.go index db8bb1773f9..8f3aa52b9e5 100644 --- a/client/v3/client_test.go +++ b/client/v3/client_test.go @@ -337,6 +337,75 @@ func TestAuthTokenBundleNoOverwrite(t *testing.T) { } } +func TestNewWithOnlyJWT(t *testing.T) { + // This call in particular changes working directory to the tmp dir of + // the test. The `etcd-auth-test:1` can be created in local directory, + // not exceeding the longest allowed path on OsX. + testutil.BeforeTest(t) + + // Create a mock AuthServer to handle Authenticate RPCs. + lis, err := net.Listen("unix", "etcd-auth-test:1") + if err != nil { + t.Fatal(err) + } + defer lis.Close() + addr := "unix://" + lis.Addr().String() + srv := grpc.NewServer() + // Having a token removes the need to ever call Authenticate on the + // server. If that happens then this will cause a connection failure. + etcdserverpb.RegisterAuthServer(srv, mockFailingAuthServer{}) + go srv.Serve(lis) + defer srv.Stop() + + c, err := NewClient(t, Config{ + DialTimeout: 5 * time.Second, + Endpoints: []string{addr}, + Token: "foo", + }) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + meta, err := c.authTokenBundle.PerRPCCredentials().GetRequestMetadata(context.Background(), "") + if err != nil { + t.Errorf("Error building request metadata: %s", err) + } + + if tok, ok := meta[rpctypes.TokenFieldNameGRPC]; !ok { + t.Error("Token was not successfuly set in the auth bundle") + } else if tok != "foo" { + t.Errorf("Incorrect token set in auth bundle, got '%s', expected 'foo'", tok) + } +} + +func TestNewOnlyJWTExclusivity(t *testing.T) { + testutil.BeforeTest(t) + + // Create a mock AuthServer to handle Authenticate RPCs. + lis, err := net.Listen("unix", "etcd-auth-test:1") + if err != nil { + t.Fatal(err) + } + defer lis.Close() + addr := "unix://" + lis.Addr().String() + srv := grpc.NewServer() + // Having a token removes the need to ever call Authenticate on the + // server. If that happens then this will cause a connection failure. + etcdserverpb.RegisterAuthServer(srv, mockFailingAuthServer{}) + go srv.Serve(lis) + defer srv.Stop() + + _, err = NewClient(t, Config{ + DialTimeout: 5 * time.Second, + Endpoints: []string{addr}, + Token: "foo", + Username: "user", + Password: "pass", + }) + require.Error(t, err, "Username/Password and Token configurations are mutually exclusive") +} + func TestSyncFiltersMembers(t *testing.T) { c, _ := NewClient(t, Config{Endpoints: []string{"http://254.0.0.1:12345"}}) defer c.Close() @@ -498,6 +567,14 @@ func (mm mockMaintenance) Downgrade(ctx context.Context, action DowngradeAction, return nil, nil } +type mockFailingAuthServer struct { + *etcdserverpb.UnimplementedAuthServer +} + +func (mockFailingAuthServer) Authenticate(context.Context, *etcdserverpb.AuthenticateRequest) (*etcdserverpb.AuthenticateResponse, error) { + return nil, errors.New("this auth server always fails") +} + type mockAuthServer struct { *etcdserverpb.UnimplementedAuthServer } diff --git a/client/v3/config.go b/client/v3/config.go index 89b40ce12ec..e643adff209 100644 --- a/client/v3/config.go +++ b/client/v3/config.go @@ -66,6 +66,9 @@ type Config struct { // Password is a password for authentication. Password string `json:"password"` + // Token is a JWT used for authentication instead of a password. + Token string `json:"token"` + // RejectOldCluster when set will refuse to create a client against an outdated cluster. RejectOldCluster bool `json:"reject-old-cluster"` @@ -128,10 +131,11 @@ type SecureConfig struct { type AuthConfig struct { Username string `json:"username"` Password string `json:"password"` + Token string `json:"token"` } func (cfg AuthConfig) Empty() bool { - return cfg.Username == "" && cfg.Password == "" + return cfg.Username == "" && cfg.Password == "" && cfg.Token == "" } // NewClientConfig creates a Config based on the provided ConfigSpec. @@ -152,6 +156,7 @@ func NewClientConfig(confSpec *ConfigSpec, lg *zap.Logger) (*Config, error) { if confSpec.Auth != nil { cfg.Username = confSpec.Auth.Username cfg.Password = confSpec.Auth.Password + cfg.Token = confSpec.Auth.Token } return cfg, nil diff --git a/client/v3/config_test.go b/client/v3/config_test.go index a99c3fd5864..0cd3ae382a7 100644 --- a/client/v3/config_test.go +++ b/client/v3/config_test.go @@ -68,6 +68,25 @@ func TestNewClientConfig(t *testing.T) { Password: "changeme", }, }, + { + name: "JWT specified", + spec: ConfigSpec{ + Endpoints: []string{"http://192.168.0.12:2379"}, + DialTimeout: 1 * time.Second, + KeepAliveTime: 4 * time.Second, + KeepAliveTimeout: 6 * time.Second, + Auth: &AuthConfig{ + Token: "test", + }, + }, + expectedConf: Config{ + Endpoints: []string{"http://192.168.0.12:2379"}, + DialTimeout: 1 * time.Second, + DialKeepAliveTime: 4 * time.Second, + DialKeepAliveTimeout: 6 * time.Second, + Token: "test", + }, + }, { name: "default secure transport", spec: ConfigSpec{ diff --git a/etcdctl/ctlv3/command/global.go b/etcdctl/ctlv3/command/global.go index 93b62068af6..38a0332f983 100644 --- a/etcdctl/ctlv3/command/global.go +++ b/etcdctl/ctlv3/command/global.go @@ -56,6 +56,7 @@ type GlobalFlags struct { User string Password string + Token string Debug bool } @@ -270,13 +271,22 @@ func authCfgFromCmd(cmd *cobra.Command) *clientv3.AuthConfig { if err != nil { cobrautl.ExitWithError(cobrautl.ExitBadArgs, err) } + tokenFlag, err := cmd.Flags().GetString("auth-jwt-token") + if err != nil { + cobrautl.ExitWithError(cobrautl.ExitBadArgs, err) + } - if userFlag == "" { + if userFlag == "" && tokenFlag == "" { return nil } var cfg clientv3.AuthConfig + if tokenFlag != "" { + cfg.Token = tokenFlag + return &cfg + } + if passwordFlag == "" { splitted := strings.SplitN(userFlag, ":", 2) if len(splitted) < 2 { diff --git a/etcdctl/ctlv3/ctl.go b/etcdctl/ctlv3/ctl.go index 4f9c31055c0..8a799fd5cca 100644 --- a/etcdctl/ctlv3/ctl.go +++ b/etcdctl/ctlv3/ctl.go @@ -70,6 +70,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&globalFlags.TLS.CertFile, "cert", "", "identify secure client using this TLS certificate file") rootCmd.PersistentFlags().StringVar(&globalFlags.TLS.KeyFile, "key", "", "identify secure client using this TLS key file") rootCmd.PersistentFlags().StringVar(&globalFlags.TLS.TrustedCAFile, "cacert", "", "verify certificates of TLS-enabled secure servers using this CA bundle") + rootCmd.PersistentFlags().StringVar(&globalFlags.Token, "auth-jwt-token", "", "JWT token used for authentication (if this option is used, --user and --password should not be set)") rootCmd.PersistentFlags().StringVar(&globalFlags.User, "user", "", "username[:password] for authentication (prompt if password is not supplied)") rootCmd.PersistentFlags().StringVar(&globalFlags.Password, "password", "", "password for authentication (if this option is used, --user option shouldn't include password)") rootCmd.PersistentFlags().StringVarP(&globalFlags.TLS.ServerName, "discovery-srv", "d", "", "domain name to query for SRV records describing cluster endpoints") diff --git a/tests/common/auth_test.go b/tests/common/auth_test.go index 0c34b800a3d..7f120bfad8a 100644 --- a/tests/common/auth_test.go +++ b/tests/common/auth_test.go @@ -29,8 +29,11 @@ import ( ) var tokenTTL = time.Second +var defaultKeyPath = mustAbsPath("../fixtures/server.key.insecure") var defaultAuthToken = fmt.Sprintf("jwt,pub-key=%s,priv-key=%s,sign-method=RS256,ttl=%s", - mustAbsPath("../fixtures/server.crt"), mustAbsPath("../fixtures/server.key.insecure"), tokenTTL) + mustAbsPath("../fixtures/server.crt"), defaultKeyPath, tokenTTL) +var verifyJWTOnlyAuth = fmt.Sprintf("jwt,pub-key=%s,sign-method=RS256,ttl=%s", + mustAbsPath("../fixtures/server.crt"), tokenTTL) const ( PermissionDenied = "etcdserver: permission denied" @@ -758,6 +761,25 @@ func TestAuthJWTExpire(t *testing.T) { }) } +func TestAuthJWTOnly(t *testing.T) { + testRunner.BeforeTest(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + clus := testRunner.NewCluster(ctx, t, config.WithClusterConfig(config.ClusterConfig{ClusterSize: 1, AuthToken: verifyJWTOnlyAuth})) + defer clus.Close() + cc := testutils.MustClient(clus.Client()) + testutils.ExecuteUntil(ctx, t, func() { + authRev, err := setupAuthAndGetRevision(cc, []authRole{testRole}, []authUser{rootUser, testUser}) + require.NoErrorf(t, err, "failed to enable auth") + + token, err := createSignedJWT(defaultKeyPath, "RS256", testUserName, authRev) + require.NoErrorf(t, err, "failed to create test user JWT") + + testUserAuthClient := testutils.MustClient(clus.Client(WithAuthToken(token))) + require.NoError(t, testUserAuthClient.Put(ctx, "foo", "bar", config.PutOptions{})) + }) +} + // TestAuthRevisionConsistency ensures auth revision is the same after member restarts func TestAuthRevisionConsistency(t *testing.T) { testRunner.BeforeTest(t) diff --git a/tests/common/auth_util.go b/tests/common/auth_util.go index 313bfb46d40..a4013822c04 100644 --- a/tests/common/auth_util.go +++ b/tests/common/auth_util.go @@ -17,8 +17,11 @@ package common import ( "context" "fmt" + "os" "testing" + "time" + "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/require" "go.etcd.io/etcd/api/v3/authpb" @@ -93,6 +96,29 @@ func createUsers(c interfaces.Client, users []authUser) error { return nil } +func createSignedJWT(keyPath, alg, username string, authRevision uint64) (string, error) { + signMethod := jwt.GetSigningMethod(alg) + + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return "", err + } + + key, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes) + if err != nil { + return "", err + } + + tk := jwt.NewWithClaims(signMethod, + jwt.MapClaims{ + "username": username, + "revision": authRevision, + "exp": time.Now().Add(time.Minute).Unix(), + }) + + return tk.SignedString(key) +} + func setupAuth(c interfaces.Client, roles []authRole, users []authUser) error { // create roles if err := createRoles(c, roles); err != nil { @@ -107,6 +133,29 @@ func setupAuth(c interfaces.Client, roles []authRole, users []authUser) error { return c.AuthEnable(context.TODO()) } +func setupAuthAndGetRevision(c interfaces.Client, roles []authRole, users []authUser) (uint64, error) { + // create roles + if err := createRoles(c, roles); err != nil { + return 0, err + } + + if err := createUsers(c, users); err != nil { + return 0, err + } + + // This needs to happen before enabling auth for the TestAuthJWTOnly + // test case because once auth is enabled we can no longer mint a valid + // auth token without the revision, which we won't be able to obtain + // without a valid auth token. + authrev, err := c.AuthStatus(context.TODO()) + if err != nil { + return 0, err + } + + // enable auth + return authrev.AuthRevision, c.AuthEnable(context.TODO()) +} + func requireRolePermissionEqual(t *testing.T, expectRole authRole, actual []*authpb.Permission) { require.Equal(t, 1, len(actual)) require.Equal(t, expectRole.permission, clientv3.PermissionType(actual[0].PermType)) diff --git a/tests/common/e2e_test.go b/tests/common/e2e_test.go index 11c4f94a335..e9e2f19215b 100644 --- a/tests/common/e2e_test.go +++ b/tests/common/e2e_test.go @@ -78,6 +78,10 @@ func WithAuth(userName, password string) config.ClientOption { return e2e.WithAuth(userName, password) } +func WithAuthToken(token string) config.ClientOption { + return e2e.WithAuthToken(token) +} + func WithEndpoints(endpoints []string) config.ClientOption { return e2e.WithEndpoints(endpoints) } diff --git a/tests/common/integration_test.go b/tests/common/integration_test.go index c4cabeeb1f9..f41055c4428 100644 --- a/tests/common/integration_test.go +++ b/tests/common/integration_test.go @@ -56,6 +56,10 @@ func WithAuth(userName, password string) config.ClientOption { return integration.WithAuth(userName, password) } +func WithAuthToken(token string) config.ClientOption { + return integration.WithAuthToken(token) +} + func WithEndpoints(endpoints []string) config.ClientOption { return integration.WithEndpoints(endpoints) } diff --git a/tests/common/unit_test.go b/tests/common/unit_test.go index 4b172e7a3cb..48e3a51cda1 100644 --- a/tests/common/unit_test.go +++ b/tests/common/unit_test.go @@ -37,6 +37,10 @@ func WithAuth(userName, password string) config.ClientOption { return func(any) {} } +func WithAuthToken(token string) config.ClientOption { + return func(any) {} +} + func WithEndpoints(endpoints []string) config.ClientOption { return func(any) {} } diff --git a/tests/framework/e2e/etcdctl.go b/tests/framework/e2e/etcdctl.go index 81d57c088d5..e9266cfb252 100644 --- a/tests/framework/e2e/etcdctl.go +++ b/tests/framework/e2e/etcdctl.go @@ -55,6 +55,7 @@ func NewEtcdctl(cfg ClientConfig, endpoints []string, opts ...config.ClientOptio DialOptions: []grpc.DialOption{grpc.WithBlock()}, Username: ctl.authConfig.Username, Password: ctl.authConfig.Password, + Token: ctl.authConfig.Token, }) if err != nil { return nil, err @@ -73,6 +74,13 @@ func WithAuth(userName, password string) config.ClientOption { } } +func WithAuthToken(token string) config.ClientOption { + return func(c any) { + ctl := c.(*EtcdctlV3) + ctl.authConfig.Token = token + } +} + func WithEndpoints(endpoints []string) config.ClientOption { return func(c any) { ctl := c.(*EtcdctlV3) @@ -344,7 +352,9 @@ func (ctl *EtcdctlV3) flags() map[string]string { } } fmap["endpoints"] = strings.Join(ctl.endpoints, ",") - if !ctl.authConfig.Empty() { + if ctl.authConfig.Token != "" { + fmap["auth-jwt-token"] = ctl.authConfig.Token + } else if !ctl.authConfig.Empty() { fmap["user"] = ctl.authConfig.Username + ":" + ctl.authConfig.Password } return fmap diff --git a/tests/framework/integration/cluster.go b/tests/framework/integration/cluster.go index 95b5c88d9f8..a164fcc721b 100644 --- a/tests/framework/integration/cluster.go +++ b/tests/framework/integration/cluster.go @@ -1478,6 +1478,13 @@ func WithAuth(userName, password string) framecfg.ClientOption { } } +func WithAuthToken(token string) framecfg.ClientOption { + return func(c any) { + cfg := c.(*clientv3.Config) + cfg.Token = token + } +} + func WithEndpoints(endpoints []string) framecfg.ClientOption { return func(c any) { cfg := c.(*clientv3.Config) diff --git a/tests/go.mod b/tests/go.mod index 57e6b857e12..2ca1ad81611 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -18,6 +18,7 @@ replace ( require ( github.com/anishathalye/porcupine v0.1.4 github.com/coreos/go-semver v0.3.1 + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang/protobuf v1.5.4 github.com/google/go-cmp v0.6.0 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 @@ -65,7 +66,6 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.2 // indirect github.com/google/uuid v1.6.0 // indirect