github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/runbits/auth/login.go (about)

     1  package auth
     2  
     3  import (
     4  	"net/url"
     5  	"time"
     6  
     7  	anaConst "github.com/ActiveState/cli/internal/analytics/constants"
     8  	"github.com/ActiveState/cli/internal/constants"
     9  	"github.com/ActiveState/cli/internal/errs"
    10  	"github.com/ActiveState/cli/internal/keypairs"
    11  	"github.com/ActiveState/cli/internal/locale"
    12  	"github.com/ActiveState/cli/internal/logging"
    13  	"github.com/ActiveState/cli/internal/osutils"
    14  	"github.com/ActiveState/cli/internal/output"
    15  	"github.com/ActiveState/cli/internal/prompt"
    16  	"github.com/ActiveState/cli/internal/rtutils/ptr"
    17  	"github.com/ActiveState/cli/pkg/platform/api"
    18  	"github.com/ActiveState/cli/pkg/platform/api/mono/mono_models"
    19  	secretsapi "github.com/ActiveState/cli/pkg/platform/api/secrets"
    20  	"github.com/ActiveState/cli/pkg/platform/authentication"
    21  	model "github.com/ActiveState/cli/pkg/platform/model/auth"
    22  	"github.com/go-openapi/strfmt"
    23  )
    24  
    25  // OpenURI aliases to osutils.OpenURI which opens the given URI in your browser. This is being exposed so that it can be
    26  // overwritten in tests
    27  var OpenURI = osutils.OpenURI
    28  
    29  // Authenticate will prompt the user for authentication
    30  func Authenticate(cfg keypairs.Configurable, out output.Outputer, prompt prompt.Prompter, auth *authentication.Auth) error {
    31  	return AuthenticateWithInput("", "", "", false, cfg, out, prompt, auth)
    32  }
    33  
    34  // AuthenticateWithInput will prompt the user for authentication if the input doesn't already provide it
    35  func AuthenticateWithInput(
    36  	username, password, totp string,
    37  	nonInteractive bool,
    38  	cfg keypairs.Configurable,
    39  	out output.Outputer,
    40  	prompt prompt.Prompter,
    41  	auth *authentication.Auth,
    42  ) error {
    43  	logging.Debug("Authenticating with input")
    44  
    45  	credentials := &mono_models.Credentials{Username: username, Password: password, Totp: totp}
    46  	if err := ensureCredentials(credentials, prompt, nonInteractive); err != nil {
    47  		return locale.WrapInputError(err, "login_cancelled")
    48  	}
    49  
    50  	err := AuthenticateWithCredentials(credentials, auth)
    51  	if err != nil {
    52  		switch {
    53  		case errs.Matches(err, &authentication.ErrTokenRequired{}):
    54  			if err := promptToken(credentials, out, prompt, auth); err != nil {
    55  				return errs.Wrap(err, "promptToken failed")
    56  			}
    57  		case errs.Matches(err, &authentication.ErrUnauthorized{}):
    58  			return locale.WrapError(err, "err_auth_failed")
    59  		default:
    60  			return locale.WrapError(err, "err_auth_failed_unknown_cause", "", err.Error())
    61  		}
    62  	}
    63  
    64  	if auth.Authenticated() {
    65  		secretsapi.InitializeClient(auth)
    66  		if err := ensureUserKeypair(credentials.Password, cfg, out, prompt, auth); err != nil {
    67  			return errs.Wrap(err, "ensureUserKeypair failed")
    68  		}
    69  	}
    70  
    71  	return nil
    72  }
    73  
    74  // AuthenticateWithToken will try to authenticate with the provided token
    75  func AuthenticateWithToken(token string, auth *authentication.Auth) error {
    76  	logging.Debug("Authenticating with token")
    77  
    78  	if err := auth.AuthenticateWithModel(&mono_models.Credentials{
    79  		Token: token,
    80  	}); err != nil {
    81  		return locale.WrapError(err, "err_auth_model", "Failed to authenticate.")
    82  	}
    83  
    84  	if err := auth.SaveToken(token); err != nil {
    85  		return locale.WrapError(err, "err_auth_token", "Failed to save token during token authentication.")
    86  	}
    87  
    88  	return nil
    89  }
    90  
    91  // RequireAuthentication will prompt the user for authentication if they are not already authenticated. If the authentication
    92  // is not successful it will return a failure
    93  func RequireAuthentication(message string, cfg keypairs.Configurable, out output.Outputer, prompt prompt.Prompter, auth *authentication.Auth) error {
    94  	if auth.Authenticated() {
    95  		return nil
    96  	}
    97  
    98  	out.Print(message)
    99  
   100  	choices := []string{
   101  		locale.T("prompt_login_browser_action"),
   102  		locale.T("prompt_login_action"),
   103  		locale.T("prompt_signup_browser_action"),
   104  	}
   105  	choice, err := prompt.Select(locale.Tl("login_signup", "Login or Signup"), locale.T("prompt_login_or_signup"), choices, new(string))
   106  	if err != nil {
   107  		return errs.Wrap(err, "Prompt cancelled")
   108  	}
   109  
   110  	switch choice {
   111  	case locale.T("prompt_login_browser_action"):
   112  		if err := AuthenticateWithBrowser(out, auth, prompt, cfg); err != nil {
   113  			return errs.Wrap(err, "Authenticate failed")
   114  		}
   115  	case locale.T("prompt_login_action"):
   116  		if err := Authenticate(cfg, out, prompt, auth); err != nil {
   117  			return errs.Wrap(err, "Authenticate failed")
   118  		}
   119  	case locale.T("prompt_signup_browser_action"):
   120  		if err := SignupWithBrowser(out, auth, prompt, cfg); err != nil {
   121  			return errs.Wrap(err, "Signup failed")
   122  		}
   123  	}
   124  
   125  	if !auth.Authenticated() {
   126  		return locale.NewInputError("err_auth_required")
   127  	}
   128  
   129  	return nil
   130  }
   131  
   132  func ensureCredentials(credentials *mono_models.Credentials, prompter prompt.Prompter, nonInteractive bool) error {
   133  	var err error
   134  	if credentials.Username == "" {
   135  		if nonInteractive {
   136  			return locale.NewInputError("err_auth_needinput")
   137  		}
   138  		credentials.Username, err = prompter.Input("", locale.T("username_prompt"), new(string), prompt.InputRequired)
   139  		if err != nil {
   140  			return errs.Wrap(err, "Input cancelled")
   141  		}
   142  	}
   143  
   144  	if credentials.Password == "" {
   145  		if nonInteractive {
   146  			return locale.NewInputError("err_auth_needinput")
   147  		}
   148  		credentials.Password, err = prompter.InputSecret("", locale.T("password_prompt"), prompt.InputRequired)
   149  		if err != nil {
   150  			return errs.Wrap(err, "Secret input cancelled")
   151  		}
   152  	}
   153  	return nil
   154  }
   155  
   156  // AuthenticateWithCredentials will attempt authenticate using the given credentials
   157  func AuthenticateWithCredentials(credentials *mono_models.Credentials, auth *authentication.Auth) error {
   158  	logging.Debug("Authenticating with credentials")
   159  
   160  	err := auth.AuthenticateWithModel(credentials)
   161  	if err != nil {
   162  		return err
   163  	}
   164  
   165  	if err := auth.CreateToken(); err != nil {
   166  		return locale.WrapError(err, "err_auth_token", "Failed to create token while authenticating with credentials.")
   167  	}
   168  
   169  	return nil
   170  }
   171  
   172  func promptToken(credentials *mono_models.Credentials, out output.Outputer, prompt prompt.Prompter, auth *authentication.Auth) error {
   173  	var err error
   174  	credentials.Totp, err = prompt.Input("", locale.T("totp_prompt"), new(string))
   175  	if err != nil {
   176  		return err
   177  	}
   178  	if credentials.Totp == "" {
   179  		out.Notice(locale.T("login_cancelled"))
   180  		return locale.NewInputError("err_auth_empty_token")
   181  	}
   182  
   183  	err = AuthenticateWithCredentials(credentials, auth)
   184  	if err != nil {
   185  		return err
   186  	}
   187  
   188  	return nil
   189  }
   190  
   191  // AuthenticateWithBrowser attempts to authenticate this device with the Platform.
   192  func AuthenticateWithBrowser(out output.Outputer, auth *authentication.Auth, prompt prompt.Prompter, cfg keypairs.Configurable) error {
   193  	logging.Debug("Authenticating with browser")
   194  
   195  	err := authenticateWithBrowser(out, auth, prompt, cfg, false)
   196  	if err != nil {
   197  		return errs.Wrap(err, "Error authenticating with browser")
   198  	}
   199  
   200  	out.Notice(locale.T("auth_device_success"))
   201  
   202  	return nil
   203  }
   204  
   205  // authenticateWithBrowser authenticates after signup if applicable.
   206  func authenticateWithBrowser(out output.Outputer, auth *authentication.Auth, prompt prompt.Prompter, cfg keypairs.Configurable, signup bool) error {
   207  	response, err := model.RequestDeviceAuthorization()
   208  	if err != nil {
   209  		return locale.WrapError(err, "err_auth_device")
   210  	}
   211  
   212  	if response.VerificationURIComplete == nil {
   213  		return errs.New("Invalid response: Missing verification URL.")
   214  	}
   215  
   216  	verificationURL := *response.VerificationURIComplete
   217  	parsedURL, err := url.Parse(verificationURL)
   218  	if err != nil {
   219  		return errs.Wrap(err, "Verification URL is not valid")
   220  	}
   221  	if signup {
   222  		// verificationURL is of the form:
   223  		//   https://platform.activestate.com/authorize/device?user-code=...
   224  		// Transform it to the form:
   225  		//   https://platform.activestate.com/create-account?nextRoute=%2Fauthorize%2Fdevice%3Fuser-code%3D...
   226  		signupURL := api.GetPlatformURL(constants.PlatformSignupPath)
   227  		query := signupURL.Query()
   228  		query.Add("nextRoute", parsedURL.RequestURI())
   229  		signupURL.RawQuery = query.Encode()
   230  		parsedURL = signupURL
   231  	}
   232  	if webclientId := cfg.GetString(anaConst.CfgSessionToken); webclientId != "" {
   233  		query := parsedURL.Query()
   234  		query.Add("webclient_id", webclientId)
   235  		parsedURL.RawQuery = query.Encode()
   236  	}
   237  	verificationURL = parsedURL.String()
   238  
   239  	// Print code to user
   240  	if response.UserCode == nil {
   241  		return errs.New("Invalid response: Missing user code.")
   242  	}
   243  	out.Notice(locale.Tr("auth_device_verify_security_code", *response.UserCode, verificationURL))
   244  
   245  	// Open URL in browser
   246  	err = OpenURI(verificationURL)
   247  	if err != nil {
   248  		logging.Warning("Could not open browser: %v", err)
   249  		out.Notice(locale.Tr("err_browser_open"))
   250  	}
   251  
   252  	var apiKey string
   253  	if !response.Nopoll {
   254  		// Wait for user to complete authentication
   255  		apiKey, err = auth.AuthenticateWithDevicePolling(strfmt.UUID(*response.DeviceCode), time.Duration(response.Interval)*time.Second)
   256  		if err != nil {
   257  			return locale.WrapError(err, "err_auth_device")
   258  		}
   259  	} else {
   260  		// This is the non-default behavior. If Nopoll = true we fall back on prompting the user to continue. It is a
   261  		// failsafe we can use in case polling overloads our API.
   262  		var cont bool
   263  		var err error
   264  		for !cont {
   265  			cont, err = prompt.Confirm(locale.Tl("continue", "Continue?"), locale.T("auth_press_enter"), ptr.To(false))
   266  			if err != nil {
   267  				return errs.Wrap(err, "Prompt failed")
   268  			}
   269  		}
   270  		apiKey, err = auth.AuthenticateWithDevice(strfmt.UUID(*response.DeviceCode))
   271  		if err != nil {
   272  			return locale.WrapError(err, "err_auth_device")
   273  		}
   274  	}
   275  
   276  	if err := auth.SaveToken(apiKey); err != nil {
   277  		return locale.WrapError(err, "err_auth_token", "Failed to create token after authenticating with browser.")
   278  	}
   279  
   280  	return nil
   281  }