github.com/vmware/govmomi@v0.51.0/cli/session/login.go (about)

     1  // © Broadcom. All Rights Reserved.
     2  // The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries.
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package session
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"encoding/base64"
    11  	"errors"
    12  	"flag"
    13  	"fmt"
    14  	"io"
    15  	"net/http"
    16  	"net/url"
    17  	"os"
    18  	"strings"
    19  	"time"
    20  
    21  	"github.com/vmware/govmomi/cli"
    22  	"github.com/vmware/govmomi/cli/flags"
    23  	"github.com/vmware/govmomi/session"
    24  	"github.com/vmware/govmomi/sts"
    25  	"github.com/vmware/govmomi/vapi/authentication"
    26  	"github.com/vmware/govmomi/vapi/rest"
    27  	"github.com/vmware/govmomi/vim25"
    28  	"github.com/vmware/govmomi/vim25/methods"
    29  	"github.com/vmware/govmomi/vim25/soap"
    30  )
    31  
    32  type login struct {
    33  	*flags.ClientFlag
    34  	*flags.OutputFlag
    35  
    36  	clone  bool
    37  	issue  bool
    38  	jwt    string
    39  	renew  bool
    40  	long   bool
    41  	vapi   bool
    42  	ticket string
    43  	life   time.Duration
    44  	cookie string
    45  	token  string
    46  	ext    string
    47  	as     string
    48  	method string
    49  }
    50  
    51  func init() {
    52  	cli.Register("session.login", &login{})
    53  }
    54  
    55  func (cmd *login) Register(ctx context.Context, f *flag.FlagSet) {
    56  	cmd.ClientFlag, ctx = flags.NewClientFlag(ctx)
    57  	cmd.ClientFlag.Register(ctx, f)
    58  	cmd.OutputFlag, ctx = flags.NewOutputFlag(ctx)
    59  	cmd.OutputFlag.Register(ctx, f)
    60  
    61  	f.BoolVar(&cmd.clone, "clone", false, "Acquire clone ticket")
    62  	f.BoolVar(&cmd.issue, "issue", false, "Issue SAML token")
    63  	f.StringVar(&cmd.jwt, "jwt", "", "Exchange SAML token for JWT audience")
    64  	f.BoolVar(&cmd.renew, "renew", false, "Renew SAML token")
    65  	f.BoolVar(&cmd.vapi, "r", false, "REST login")
    66  	f.DurationVar(&cmd.life, "lifetime", time.Minute*10, "SAML token lifetime")
    67  	f.BoolVar(&cmd.long, "l", false, "Output session cookie")
    68  	f.StringVar(&cmd.ticket, "ticket", "", "Use clone ticket for login")
    69  	f.StringVar(&cmd.cookie, "cookie", "", "Set HTTP cookie for an existing session")
    70  	f.StringVar(&cmd.token, "token", "", "Use SAML token for login or as issue identity")
    71  	f.StringVar(&cmd.ext, "extension", "", "Extension name")
    72  	f.StringVar(&cmd.as, "as", "", "Impersonate user")
    73  	f.StringVar(&cmd.method, "X", "", "HTTP method")
    74  }
    75  
    76  func (cmd *login) Process(ctx context.Context) error {
    77  	if err := cmd.OutputFlag.Process(ctx); err != nil {
    78  		return err
    79  	}
    80  	return cmd.ClientFlag.Process(ctx)
    81  }
    82  
    83  func (cmd *login) Usage() string {
    84  	return "[PATH]"
    85  }
    86  
    87  func (cmd *login) Description() string {
    88  	return `Session login.
    89  
    90  The session.login command is optional, all other govc commands will auto login when given credentials.
    91  The session.login command can be used to:
    92  - Persist a session without writing to disk via the '-cookie' flag
    93  - Acquire a clone ticket
    94  - Login using a clone ticket
    95  - Login using a vCenter Extension certificate
    96  - Issue a SAML token
    97  - Renew a SAML token
    98  - Exchange a SAML token for a JSON Web Token (JWT)
    99  - Login using a SAML token
   100  - Impersonate a user
   101  - Avoid passing credentials to other govc commands
   102  - Send an authenticated raw HTTP request
   103  
   104  The session.login command can be used for authenticated curl-style HTTP requests when a PATH arg is given.
   105  PATH may also contain a query string. The '-u' flag (GOVC_URL) is used for the URL scheme, host and port.
   106  The request method (-X) defaults to GET. When set to POST, PUT or PATCH, a request body must be provided via stdin.
   107  
   108  Examples:
   109    govc session.login -u root:password@host # Creates a cached session in ~/.govmomi/sessions
   110    govc session.ls -u root@host # Use the cached session with another command
   111    ticket=$(govc session.login -u root@host -clone)
   112    govc session.login -u root@host -ticket $ticket
   113    govc session.login -u Administrator@vsphere.local:password@host -as other@vsphere.local
   114    govc session.login -u host -extension com.vmware.vsan.health -cert rui.crt -key rui.key
   115    token=$(govc session.login -u host -cert user.crt -key user.key -issue) # HoK token
   116    bearer=$(govc session.login -u user:pass@host -issue) # Bearer token
   117    token=$(govc session.login -u host -cert user.crt -key user.key -issue -token "$bearer")
   118    govc session.login -u host -cert user.crt -key user.key -token "$token"
   119    token=$(govc session.login -u host -cert user.crt -key user.key -renew -lifetime 24h -token "$token")
   120    govc session.login -jwt vmware-tes:vc:nsxd-v2:nsx -token "$token"
   121    # HTTP requests
   122    govc session.login -r -X GET /api/vcenter/namespace-management/clusters | jq .
   123    govc session.login -r -X POST /rest/vcenter/cluster/modules <<<'{"spec": {"cluster": "domain-c9"}}'`
   124  }
   125  
   126  type ticketResult struct {
   127  	cmd    *login
   128  	Ticket string `json:",omitempty"`
   129  	Token  string `json:",omitempty"`
   130  	Cookie string `json:",omitempty"`
   131  }
   132  
   133  func (r *ticketResult) Write(w io.Writer) error {
   134  	var output []string
   135  
   136  	for _, val := range []string{r.Ticket, r.Token, r.Cookie} {
   137  		if val != "" {
   138  			output = append(output, val)
   139  		}
   140  	}
   141  
   142  	if len(output) == 0 {
   143  		return nil
   144  	}
   145  
   146  	fmt.Fprintln(w, strings.Join(output, " "))
   147  
   148  	return nil
   149  }
   150  
   151  // Logout is called by cli.Run()
   152  // We override ClientFlag's Logout impl to avoid ending a session when -persist-session=false,
   153  // otherwise Logout would invalidate the cookie and/or ticket.
   154  func (cmd *login) Logout(ctx context.Context) error {
   155  	if cmd.long || cmd.clone || cmd.issue {
   156  		return nil
   157  	}
   158  	return cmd.ClientFlag.Logout(ctx)
   159  }
   160  
   161  func (cmd *login) cloneSession(ctx context.Context, c *vim25.Client) error {
   162  	return session.NewManager(c).CloneSession(ctx, cmd.ticket)
   163  }
   164  
   165  func (cmd *login) issueToken(ctx context.Context, vc *vim25.Client) (string, error) {
   166  	c, err := sts.NewClient(ctx, vc)
   167  	if err != nil {
   168  		return "", err
   169  	}
   170  	c.RoundTripper = cmd.RoundTripper(c.Client)
   171  
   172  	req := sts.TokenRequest{
   173  		Certificate: c.Certificate(),
   174  		Userinfo:    cmd.Session.URL.User,
   175  		Renewable:   true,
   176  		Delegatable: true,
   177  		ActAs:       cmd.token != "",
   178  		Token:       cmd.token,
   179  		Lifetime:    cmd.life,
   180  	}
   181  
   182  	issue := c.Issue
   183  	if cmd.renew {
   184  		issue = c.Renew
   185  	}
   186  
   187  	s, err := issue(ctx, req)
   188  	if err != nil {
   189  		return "", err
   190  	}
   191  
   192  	if req.Token != "" {
   193  		duration := s.Lifetime.Expires.Sub(s.Lifetime.Created)
   194  		if duration < req.Lifetime {
   195  			// The granted lifetime is that of the bearer token, which is 5min max.
   196  			// Extend the lifetime via Renew.
   197  			req.Token = s.Token
   198  			if s, err = c.Renew(ctx, req); err != nil {
   199  				return "", err
   200  			}
   201  		}
   202  	}
   203  
   204  	return s.Token, nil
   205  }
   206  
   207  func (cmd *login) exchangeTokenJWT(ctx context.Context, c *rest.Client) (string, error) {
   208  	spec := authentication.TokenIssueSpec{
   209  		Audience:           cmd.jwt,
   210  		GrantType:          "urn:ietf:params:oauth:grant-type:token-exchange",
   211  		RequestedTokenType: "urn:ietf:params:oauth:token-type:id_token",
   212  		SubjectToken:       base64.StdEncoding.EncodeToString([]byte(cmd.token)),
   213  		SubjectTokenType:   "urn:ietf:params:oauth:token-type:saml2",
   214  	}
   215  
   216  	info, err := authentication.NewManager(c).Issue(ctx, spec)
   217  	if err != nil {
   218  		return "", err
   219  	}
   220  	return info.AccessToken, nil
   221  }
   222  
   223  func (cmd *login) loginByToken(ctx context.Context, c *vim25.Client) error {
   224  	header := soap.Header{
   225  		Security: &sts.Signer{
   226  			Certificate: c.Certificate(),
   227  			Token:       cmd.token,
   228  		},
   229  	}
   230  
   231  	// something behind the LoginByToken scene requires a version from /sdk/vimServiceVersions.xml
   232  	// in the SOAPAction header. For example, if vim25.Version is "7.0" but the service version is "6.3",
   233  	// LoginByToken fails with: 'VersionMismatchFaultCode: Unsupported version URI "urn:vim25/7.0"'
   234  	if c.Version == vim25.Version {
   235  		_ = c.UseServiceVersion()
   236  	}
   237  
   238  	return session.NewManager(c).LoginByToken(c.WithHeader(ctx, header))
   239  }
   240  
   241  func (cmd *login) loginRestByToken(ctx context.Context, c *rest.Client) error {
   242  	signer := &sts.Signer{
   243  		Certificate: c.Certificate(),
   244  		Token:       cmd.token,
   245  	}
   246  
   247  	return c.LoginByToken(c.WithSigner(ctx, signer))
   248  }
   249  
   250  func (cmd *login) loginByExtension(ctx context.Context, c *vim25.Client) error {
   251  	return session.NewManager(c).LoginExtensionByCertificate(ctx, cmd.ext)
   252  }
   253  
   254  func (cmd *login) impersonateUser(ctx context.Context, c *vim25.Client) error {
   255  	m := session.NewManager(c)
   256  	if err := m.Login(ctx, cmd.Session.URL.User); err != nil {
   257  		return err
   258  	}
   259  	return m.ImpersonateUser(ctx, cmd.as)
   260  }
   261  
   262  func (cmd *login) setCookie(ctx context.Context, c *vim25.Client) error {
   263  	url := c.URL()
   264  	jar := c.Client.Jar
   265  	cookies := jar.Cookies(url)
   266  	add := true
   267  
   268  	cookie := &http.Cookie{
   269  		Name: soap.SessionCookieName,
   270  	}
   271  
   272  	for _, e := range cookies {
   273  		if e.Name == cookie.Name {
   274  			add = false
   275  			cookie = e
   276  			break
   277  		}
   278  	}
   279  
   280  	if cmd.cookie == "" {
   281  		// This is the cookie from Set-Cookie after a Login or CloneSession
   282  		cmd.cookie = cookie.Value
   283  	} else {
   284  		// The cookie flag is set, set the HTTP header and skip Login()
   285  		cookie.Value = cmd.cookie
   286  		if add {
   287  			cookies = append(cookies, cookie)
   288  		}
   289  		jar.SetCookies(url, cookies)
   290  
   291  		// Check the session is still valid
   292  		_, err := methods.GetCurrentTime(ctx, c)
   293  		if err != nil {
   294  			return err
   295  		}
   296  	}
   297  
   298  	return nil
   299  }
   300  
   301  func (cmd *login) setRestCookie(ctx context.Context, c *rest.Client) error {
   302  	if cmd.cookie == "" {
   303  		cmd.cookie = c.SessionID()
   304  	} else {
   305  		c.SessionID(cmd.cookie)
   306  
   307  		// Check the session is still valid
   308  		s, err := c.Session(ctx)
   309  		if err != nil {
   310  			return err
   311  		}
   312  		if s == nil {
   313  			return errors.New(http.StatusText(http.StatusUnauthorized))
   314  		}
   315  	}
   316  
   317  	return nil
   318  }
   319  
   320  func nologinSOAP(_ context.Context, _ *vim25.Client) error {
   321  	return nil
   322  }
   323  
   324  func nologinREST(_ context.Context, _ *rest.Client) error {
   325  	return nil
   326  }
   327  
   328  func (cmd *login) Run(ctx context.Context, f *flag.FlagSet) error {
   329  	if cmd.renew {
   330  		cmd.issue = true
   331  	}
   332  	switch {
   333  	case cmd.ticket != "":
   334  		cmd.Session.LoginSOAP = cmd.cloneSession
   335  	case cmd.cookie != "":
   336  		if cmd.vapi {
   337  			cmd.Session.LoginSOAP = nologinSOAP
   338  			cmd.Session.LoginREST = cmd.setRestCookie
   339  		} else {
   340  			cmd.Session.LoginSOAP = cmd.setCookie
   341  			cmd.Session.LoginREST = nologinREST
   342  		}
   343  	case cmd.token != "":
   344  		cmd.Session.LoginSOAP = cmd.loginByToken
   345  		cmd.Session.LoginREST = cmd.loginRestByToken
   346  	case cmd.ext != "":
   347  		cmd.Session.LoginSOAP = cmd.loginByExtension
   348  	case cmd.as != "":
   349  		cmd.Session.LoginSOAP = cmd.impersonateUser
   350  	case cmd.issue:
   351  		cmd.Session.LoginSOAP = nologinSOAP
   352  		cmd.Session.LoginREST = nologinREST
   353  	case cmd.jwt != "":
   354  		cmd.Session.LoginSOAP = nologinSOAP
   355  	}
   356  
   357  	c, err := cmd.Client()
   358  	if err != nil {
   359  		return err
   360  	}
   361  
   362  	r := &ticketResult{cmd: cmd}
   363  
   364  	var rc *rest.Client
   365  	if cmd.vapi || cmd.jwt != "" {
   366  		rc, err = cmd.RestClient()
   367  		if err != nil {
   368  			return err
   369  		}
   370  	}
   371  
   372  	switch {
   373  	case cmd.clone:
   374  		m := session.NewManager(c)
   375  		r.Ticket, err = m.AcquireCloneTicket(ctx)
   376  		if err != nil {
   377  			return err
   378  		}
   379  	case cmd.issue:
   380  		r.Token, err = cmd.issueToken(ctx, c)
   381  		if err != nil {
   382  			return err
   383  		}
   384  		return cmd.WriteResult(r)
   385  	case cmd.jwt != "":
   386  		r.Token, err = cmd.exchangeTokenJWT(ctx, rc)
   387  	}
   388  
   389  	if f.NArg() == 1 {
   390  		u, err := url.Parse(f.Arg(0))
   391  		if err != nil {
   392  			return err
   393  		}
   394  		vc := c.URL()
   395  		u.Scheme = vc.Scheme
   396  		u.Host = vc.Host
   397  
   398  		var body io.Reader
   399  
   400  		switch cmd.method {
   401  		case http.MethodPost, http.MethodPut, http.MethodPatch:
   402  			// strings.Reader here as /api wants a Content-Length header
   403  			b, err := io.ReadAll(os.Stdin)
   404  			if err != nil {
   405  				return err
   406  			}
   407  			body = bytes.NewReader(b)
   408  		default:
   409  			body = strings.NewReader("")
   410  		}
   411  
   412  		req, err := http.NewRequest(cmd.method, u.String(), body)
   413  		if err != nil {
   414  			return err
   415  		}
   416  
   417  		if cmd.vapi {
   418  			return rc.Do(ctx, req, cmd.Out)
   419  		}
   420  
   421  		return c.Do(ctx, req, func(res *http.Response) error {
   422  			if res.StatusCode != http.StatusOK {
   423  				return errors.New(res.Status)
   424  			}
   425  			_, err := io.Copy(cmd.Out, res.Body)
   426  			return err
   427  		})
   428  	}
   429  
   430  	if cmd.cookie == "" {
   431  		if cmd.vapi {
   432  			_ = cmd.setRestCookie(ctx, rc)
   433  		} else {
   434  			_ = cmd.setCookie(ctx, c)
   435  		}
   436  		if cmd.cookie == "" {
   437  			return flag.ErrHelp
   438  		}
   439  	}
   440  
   441  	if cmd.long {
   442  		r.Cookie = cmd.cookie
   443  	}
   444  
   445  	return cmd.WriteResult(r)
   446  }