Merge 4f46fb4465b8bae610fe2e9c4c3c5d4d2f64ccf4 into c86c93ca2951338115159dcdd20711603044e1f1

This commit is contained in:
Mike Crute 2024-09-26 09:29:17 +08:00 committed by GitHub
commit 3aee8ffb9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 236 additions and 6 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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{

View File

@ -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 {

View File

@ -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")

View File

@ -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)

View File

@ -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))

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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) {}
}

View File

@ -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

View File

@ -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)

View File

@ -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.3 // indirect
github.com/google/uuid v1.6.0 // indirect