crossplane
287 строк · 8.1 Кб
1/*
2Copyright 2023 The Crossplane Authors.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16
17package xpkg
18
19import (
20"bufio"
21"bytes"
22"context"
23"crypto/tls"
24"encoding/json"
25"fmt"
26"io"
27"net/http"
28"os"
29"strings"
30"time"
31
32"github.com/alecthomas/kong"
33"github.com/golang-jwt/jwt/v5"
34"github.com/upbound/up-sdk-go/service/userinfo"
35"golang.org/x/term"
36
37"github.com/crossplane/crossplane-runtime/pkg/errors"
38
39"github.com/crossplane/crossplane/internal/xpkg/upbound"
40"github.com/crossplane/crossplane/internal/xpkg/upbound/config"
41)
42
43const (
44defaultTimeout = 30 * time.Second
45loginPath = "/v1/login"
46defaultProfileName = "default"
47)
48
49type loginCmd struct {
50// Flags. We're intentionally making an exception to the rule here and not
51// sorting these alphabetically.
52Username string `short:"u" env:"UP_USER" xor:"identifier" help:"Username used to authenticate."`
53Password string `short:"p" env:"UP_PASSWORD" help:"Password for specified username. '-' to read from stdin."`
54Token string `short:"t" env:"UP_TOKEN" xor:"identifier" help:"Token used to authenticate. '-' to read from stdin."`
55
56// Common Upbound API configuration.
57upbound.Flags `embed:""`
58
59// Internal state. These aren't part of the user-exposed CLI structure.
60stdin *os.File
61client *http.Client
62}
63
64// Help prints out the help for the login command.
65func (c *loginCmd) Help() string {
66return `
67This command logs in to the xpkg.upbound.io package registry. The Crossplane CLI
68uses xpkg.upbound.io if you don't explicitly specify a different registry.
69
70You can create an xpkg.upbound.io account at https://accounts.upbound.io.
71`
72}
73
74// BeforeApply sets default values in login before assignment and validation.
75func (c *loginCmd) BeforeApply() error {
76c.stdin = os.Stdin
77return nil
78}
79
80func (c *loginCmd) AfterApply(kongCtx *kong.Context) error {
81upCtx, err := upbound.NewFromFlags(c.Flags, upbound.AllowMissingProfile())
82if err != nil {
83return err
84}
85c.client = &http.Client{
86Transport: &http.Transport{
87TLSClientConfig: &tls.Config{
88InsecureSkipVerify: upCtx.InsecureSkipTLSVerify, //nolint:gosec // we need to support insecure connections if requested
89},
90},
91}
92kongCtx.Bind(upCtx)
93if c.Token != "" {
94return nil
95}
96if err := c.setupCredentials(); err != nil {
97return errors.Wrapf(err, "failed to get credentials")
98}
99return nil
100}
101
102// Run executes the login command.
103func (c *loginCmd) Run(k *kong.Context, upCtx *upbound.Context) error { //nolint:gocyclo // TODO(phisco): refactor
104auth, profType, err := constructAuth(c.Username, c.Token, c.Password)
105if err != nil {
106return errors.Wrap(err, "failed to construct auth")
107}
108jsonStr, err := json.Marshal(auth)
109if err != nil {
110return errors.Wrap(err, "failed to marshal auth")
111}
112ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
113defer cancel()
114loginEndpoint := *upCtx.APIEndpoint
115loginEndpoint.Path = loginPath
116req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginEndpoint.String(), bytes.NewReader(jsonStr))
117if err != nil {
118return errors.Wrap(err, "failed to create request")
119}
120req.Header.Set("Content-Type", "application/json")
121res, err := c.client.Do(req)
122if err != nil {
123return errors.Wrap(err, "failed to send request")
124}
125defer res.Body.Close() //nolint:errcheck // we don't care about the error here
126session, err := extractSession(res, upbound.CookieName)
127if err != nil {
128return errors.Wrap(err, "failed to extract session")
129}
130
131// Set session early so that it can be used to fetch user info if necessary.
132upCtx.Profile.Session = session
133
134// If the default account is not set, the user's personal account is used.
135if upCtx.Account == "" {
136conf, err := upCtx.BuildSDKConfig()
137if err != nil {
138return errors.Wrap(err, "failed to build SDK config")
139}
140info, err := userinfo.NewClient(conf).Get(ctx)
141if err != nil {
142return errors.Wrap(err, "failed to get user info")
143}
144upCtx.Account = info.User.Username
145}
146
147// If profile name was not provided and no default exists, set name to 'default'.
148if upCtx.ProfileName == "" {
149upCtx.ProfileName = defaultProfileName
150}
151
152upCtx.Profile.ID = auth.ID
153upCtx.Profile.Type = profType
154upCtx.Profile.Account = upCtx.Account
155
156if err := upCtx.Cfg.AddOrUpdateUpboundProfile(upCtx.ProfileName, upCtx.Profile); err != nil {
157return errors.Wrap(err, "failed to add or update profile")
158}
159if err := upCtx.Cfg.SetDefaultUpboundProfile(upCtx.ProfileName); err != nil {
160return errors.Wrap(err, "failed to set default profile")
161}
162if err := upCtx.CfgSrc.UpdateConfig(upCtx.Cfg); err != nil {
163return errors.Wrap(err, "failed to update config")
164}
165fmt.Fprintln(k.Stdout, "Login successful.")
166return nil
167}
168
169func (c *loginCmd) setupCredentials() error {
170if c.Token == "-" {
171b, err := io.ReadAll(c.stdin)
172if err != nil {
173return err
174}
175c.Token = strings.TrimSpace(string(b))
176}
177if c.Password == "-" {
178b, err := io.ReadAll(c.stdin)
179if err != nil {
180return err
181}
182c.Password = strings.TrimSpace(string(b))
183}
184if c.Token == "" {
185if c.Username == "" {
186username, err := getUsername(c.stdin)
187if err != nil {
188return errors.Wrap(err, "failed to get username")
189}
190c.Username = username
191}
192if c.Password == "" {
193password, err := getPassword(c.stdin)
194if err != nil {
195return errors.Wrap(err, "failed to get password")
196}
197c.Password = password
198}
199}
200return nil
201}
202
203func getPassword(f *os.File) (string, error) {
204if !term.IsTerminal(int(f.Fd())) {
205return "", errors.New("not a terminal")
206}
207fmt.Fprintf(f, "Password: ")
208password, err := term.ReadPassword(int(f.Fd()))
209if err != nil {
210return "", err
211}
212// Print a new line because ReadPassword does not.
213_, _ = fmt.Fprintf(f, "\n")
214return string(password), nil
215
216}
217func getUsername(f *os.File) (string, error) {
218if !term.IsTerminal(int(f.Fd())) {
219return "", errors.New("not a terminal")
220}
221fmt.Fprintf(f, "Username: ")
222reader := bufio.NewReader(f)
223s, err := reader.ReadString('\n')
224if err != nil {
225return "", err
226}
227return strings.TrimSpace(s), nil
228}
229
230// auth is the request body sent to authenticate a user or token.
231type auth struct {
232ID string `json:"id"`
233Password string `json:"password"`
234Remember bool `json:"remember"`
235}
236
237// constructAuth constructs the body of an Upbound Cloud authentication request
238// given the provided credentials.
239func constructAuth(username, token, password string) (*auth, config.ProfileType, error) {
240if username == "" && token == "" {
241return nil, "", errors.New("no user or token provided")
242}
243id, profType, err := parseID(username, token)
244if err != nil {
245return nil, "", err
246}
247if profType == config.TokenProfileType {
248password = token
249}
250return &auth{
251ID: id,
252Password: password,
253Remember: true,
254}, profType, nil
255}
256
257// parseID gets a user ID by either parsing a token or returning the username.
258func parseID(user, token string) (string, config.ProfileType, error) {
259if token != "" {
260p := jwt.Parser{}
261claims := &jwt.RegisteredClaims{}
262_, _, err := p.ParseUnverified(token, claims)
263if err != nil {
264return "", "", err
265}
266if claims.ID == "" {
267return "", "", errors.New("no id in token")
268}
269return claims.ID, config.TokenProfileType, nil
270}
271return user, config.UserProfileType, nil
272}
273
274// extractSession extracts the specified cookie from an HTTP response. The
275// caller is responsible for closing the response body.
276func extractSession(res *http.Response, cookieName string) (string, error) {
277for _, cook := range res.Cookies() {
278if cook.Name == cookieName {
279return cook.Value, nil
280}
281}
282b, err := io.ReadAll(res.Body)
283if err != nil {
284return "", errors.Wrap(err, "failed to read body")
285}
286return "", errors.Errorf("failed to read cookie format: %v", string(b))
287}
288