github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/pkg/cli/cmd/auth/authorize/authenticator/device_authenticator.go (about)

     1  /*
     2  Copyright (C) 2022-2023 ApeCloud Co., Ltd
     3  
     4  This file is part of KubeBlocks project
     5  
     6  This program is free software: you can redistribute it and/or modify
     7  it under the terms of the GNU Affero General Public License as published by
     8  the Free Software Foundation, either version 3 of the License, or
     9  (at your option) any later version.
    10  
    11  This program is distributed in the hope that it will be useful
    12  but WITHOUT ANY WARRANTY; without even the implied warranty of
    13  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    14  GNU Affero General Public License for more details.
    15  
    16  You should have received a copy of the GNU Affero General Public License
    17  along with this program.  If not, see <http://www.gnu.org/licenses/>.
    18  */
    19  
    20  package authenticator
    21  
    22  import (
    23  	"context"
    24  	"encoding/json"
    25  	"fmt"
    26  	"net/http"
    27  	"net/url"
    28  	"strings"
    29  	"time"
    30  
    31  	"github.com/benbjohnson/clock"
    32  	"github.com/hashicorp/go-cleanhttp"
    33  	"github.com/pkg/errors"
    34  )
    35  
    36  // DeviceAuthenticator performs the authentication flow for logging in.
    37  type DeviceAuthenticator struct {
    38  	client       *http.Client
    39  	AuthURL      *url.URL
    40  	AuthAudience string
    41  	Clock        clock.Clock
    42  	ClientID     string
    43  }
    44  
    45  type DeviceVerification struct {
    46  	// DeviceCode is the unique code for the device. When the user goes to the VerificationURL in their browser-based device, this code will be bound to their session.
    47  	DeviceCode string
    48  	// UserCode contains the code that should be input at the VerificationURL to authorize the device.
    49  	UserCode string
    50  	// VerificationURL contains the URL the user should visit to authorize the device.
    51  	VerificationURL string
    52  	// VerificationCompleteURL contains the complete URL the user should visit to authorize the device. This allows your app to embed the user_code in the URL, if you so choose.
    53  	VerificationCompleteURL string
    54  	// CheckInterval indicates the interval (in seconds) at which the app should poll the token URL to request a token.
    55  	Interval time.Duration
    56  	// ExpiresAt indicates the lifetime (in seconds) of the device_code and user_code.
    57  	ExpiresAt time.Time
    58  }
    59  
    60  type DeviceCodeResponse struct {
    61  	DeviceCode              string `json:"device_code"`
    62  	UserCode                string `json:"user_code"`
    63  	VerificationURI         string `json:"verification_uri"`
    64  	VerificationCompleteURI string `json:"verification_uri_complete"`
    65  	ExpiresIn               int    `json:"expires_in"`
    66  	PollingInterval         int    `json:"interval"`
    67  }
    68  
    69  type ErrorResponse struct {
    70  	ErrorCode   string `json:"error"`
    71  	Description string `json:"error_description"`
    72  }
    73  
    74  func (e ErrorResponse) Error() string {
    75  	return e.Description
    76  }
    77  
    78  func newDeviceAuthenticator(client *http.Client, clientID string, authURL string) (*DeviceAuthenticator, error) {
    79  	if client == nil {
    80  		client = cleanhttp.DefaultClient()
    81  	}
    82  
    83  	baseURL, err := url.Parse(authURL)
    84  	if err != nil {
    85  		return nil, err
    86  	}
    87  
    88  	authenticator := &DeviceAuthenticator{
    89  		client:       client,
    90  		AuthURL:      baseURL,
    91  		AuthAudience: authURL + "/api/v2/",
    92  		Clock:        clock.New(),
    93  		ClientID:     clientID,
    94  	}
    95  	return authenticator, nil
    96  }
    97  
    98  // GetAuthorization performs the device verification API calls.
    99  func (d *DeviceAuthenticator) GetAuthorization(ctx context.Context, openURLFunc func(URL string), states ...string) (interface{}, error) {
   100  	req, err := d.newRequest(ctx, "oauth/device/code", url.Values{
   101  		"client_id": []string{d.ClientID},
   102  		"audience":  []string{d.AuthAudience},
   103  		"scope": []string{strings.Join([]string{
   104  			"read_databases", "write_databases", "read_user", "read_organization", "offline_access", "openid", "profile", "email",
   105  		}, " ")},
   106  	})
   107  	if err != nil {
   108  		return nil, err
   109  	}
   110  	res, err := d.client.Do(req)
   111  	if err != nil {
   112  		return nil, err
   113  	}
   114  	defer res.Body.Close()
   115  
   116  	if _, err = checkErrorResponse(res); err != nil {
   117  		return nil, err
   118  	}
   119  
   120  	deviceCodeRes := &DeviceCodeResponse{}
   121  	err = json.NewDecoder(res.Body).Decode(deviceCodeRes)
   122  	if err != nil {
   123  		return nil, errors.Wrap(err, "error decoding device code response")
   124  	}
   125  
   126  	interval := time.Duration(deviceCodeRes.PollingInterval) * time.Second
   127  	if interval == 0 {
   128  		interval = time.Duration(5) * time.Second
   129  	}
   130  
   131  	expiresAt := d.Clock.Now().Add(time.Duration(deviceCodeRes.ExpiresIn) * time.Second)
   132  
   133  	openURLFunc(deviceCodeRes.VerificationURI)
   134  
   135  	return &DeviceVerification{
   136  		DeviceCode:              deviceCodeRes.DeviceCode,
   137  		UserCode:                deviceCodeRes.UserCode,
   138  		VerificationCompleteURL: deviceCodeRes.VerificationCompleteURI,
   139  		VerificationURL:         deviceCodeRes.VerificationURI,
   140  		ExpiresAt:               expiresAt,
   141  		Interval:                interval,
   142  	}, nil
   143  }
   144  
   145  func (d *DeviceAuthenticator) GetToken(ctx context.Context, authorization interface{}) (*TokenResponse, error) {
   146  	v, ok := authorization.(*DeviceVerification)
   147  	if !ok {
   148  		return nil, errors.New("invalid authorization")
   149  	}
   150  
   151  	for {
   152  		// This loop begins right after we open the user's browser to send an
   153  		// authentication code. We don't request a token immediately because the
   154  		// has to complete that authentication flow before we can provide a
   155  		// token anyway.
   156  		select {
   157  		case <-ctx.Done():
   158  			return nil, ctx.Err()
   159  		case <-time.After(v.Interval):
   160  		}
   161  		tokenResponse, err := d.requestToken(ctx, v.DeviceCode, d.ClientID)
   162  		if err != nil {
   163  			// Fatal error.
   164  			return nil, err
   165  		}
   166  
   167  		if tokenResponse != nil {
   168  			return tokenResponse, nil
   169  		}
   170  
   171  		if tokenResponse == nil && d.Clock.Now().After(v.ExpiresAt) {
   172  			return nil, errors.New("authentication timed out")
   173  		}
   174  	}
   175  }
   176  
   177  func (d *DeviceAuthenticator) requestToken(ctx context.Context, deviceCode string, clientID string) (*TokenResponse, error) {
   178  	req, err := d.newRequest(ctx, "oauth/token", url.Values{
   179  		"grant_type":  []string{"urn:ietf:params:oauth:grant-type:device_code"},
   180  		"device_code": []string{deviceCode},
   181  		"client_id":   []string{clientID},
   182  	})
   183  	if err != nil {
   184  		return nil, errors.Wrap(err, "error creating request")
   185  	}
   186  
   187  	res, err := d.client.Do(req)
   188  	if err != nil {
   189  		return nil, errors.Wrap(err, "error performing http request")
   190  	}
   191  	defer res.Body.Close()
   192  
   193  	isRetryable, err := checkErrorResponse(res)
   194  	if err != nil {
   195  		return nil, err
   196  	}
   197  
   198  	if isRetryable {
   199  		return nil, nil
   200  	}
   201  
   202  	tokenRes := &TokenResponse{}
   203  
   204  	err = json.NewDecoder(res.Body).Decode(tokenRes)
   205  	if err != nil {
   206  		return nil, errors.Wrap(err, "error decoding token response")
   207  	}
   208  	return tokenRes, nil
   209  }
   210  
   211  func (d *DeviceAuthenticator) GetUserInfo(ctx context.Context, token string) (*UserInfoResponse, error) {
   212  	req, err := d.newRequest(ctx, "userinfo", url.Values{
   213  		"access_token": []string{token},
   214  	})
   215  	if err != nil {
   216  		return nil, errors.Wrap(err, "error creating request")
   217  	}
   218  
   219  	res, err := d.client.Do(req)
   220  	if err != nil {
   221  		return nil, errors.Wrap(err, "error performing http request")
   222  	}
   223  	defer res.Body.Close()
   224  
   225  	userInfo := &UserInfoResponse{}
   226  	err = json.NewDecoder(res.Body).Decode(userInfo)
   227  	if err != nil {
   228  		return nil, errors.Wrap(err, "error decoding userinfo")
   229  	}
   230  
   231  	return userInfo, err
   232  }
   233  
   234  // RefreshToken The device authenticator needs the clientSecret when refreshing the token,
   235  // and the kbcli client does not hold it, so this method does not need to be implemented.
   236  func (d *DeviceAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
   237  	return nil, nil
   238  }
   239  
   240  func (d *DeviceAuthenticator) Logout(ctx context.Context, token string, openURLFunc func(URL string)) error {
   241  	req, err := d.newRequest(ctx, "oidc/logout", url.Values{
   242  		"id_token_hint": []string{token},
   243  		"client_id":     []string{d.ClientID},
   244  	})
   245  	if err != nil {
   246  		return errors.Wrap(err, "error creating request")
   247  	}
   248  
   249  	res, err := d.client.Do(req)
   250  	fmt.Println(res.StatusCode)
   251  	if err != nil {
   252  		return errors.Wrap(err, "error performing http request")
   253  	}
   254  	defer res.Body.Close()
   255  	if _, err = checkErrorResponse(res); err != nil {
   256  		return err
   257  	}
   258  
   259  	logoutURL := fmt.Sprintf(d.AuthURL.Path + "/oidc/logout?federated")
   260  	openURLFunc(logoutURL)
   261  
   262  	return nil
   263  }
   264  
   265  func (d *DeviceAuthenticator) newRequest(ctx context.Context, path string, payload url.Values) (*http.Request, error) {
   266  	u, err := d.AuthURL.Parse(path)
   267  	if err != nil {
   268  		return nil, err
   269  	}
   270  
   271  	req, err := http.NewRequestWithContext(
   272  		ctx,
   273  		http.MethodPost,
   274  		u.String(),
   275  		strings.NewReader(payload.Encode()),
   276  	)
   277  	if err != nil {
   278  		return nil, err
   279  	}
   280  
   281  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   282  	req.Header.Set("Accept", "application/json")
   283  	return req, nil
   284  }
   285  
   286  // CheckErrorResponse returns whether the error is retryable or not and the error itself.
   287  func checkErrorResponse(res *http.Response) (bool, error) {
   288  	if res.StatusCode < 400 {
   289  		return false, nil
   290  	}
   291  
   292  	// Client or server error.
   293  	errorRes := &ErrorResponse{}
   294  	err := json.NewDecoder(res.Body).Decode(errorRes)
   295  	if err != nil {
   296  		return false, errors.Wrap(err, "error decoding response")
   297  	}
   298  
   299  	// Authentication is not yet complete or requests need to be slowed down.
   300  	if errorRes.ErrorCode == "authorization_pending" || errorRes.ErrorCode == "slow_down" {
   301  		return true, nil
   302  	}
   303  
   304  	return false, errorRes
   305  }