package cli import ( "errors" "fmt" "io" "net/url" "os" "os/exec" "os/user" "path" "runtime" "strings" "github.com/go-playground/validator/v10" "github.com/pkg/browser" "golang.org/x/xerrors" "github.com/coder/pretty" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/coderd/userpassword" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) const ( goosWindows = "windows" goosDarwin = "darwin" ) func init() { // Hide output from the browser library, // otherwise we can get really verbose and non-actionable messages // when in SSH or another type of headless session // NOTE: This needs to be in `init` to prevent data races // (multiple threads trying to set the global browser.Std* variables) browser.Stderr = io.Discard browser.Stdout = io.Discard } func promptFirstUsername(inv *serpent.Invocation) (string, error) { currentUser, err := user.Current() if err != nil { return "", xerrors.Errorf("get current user: %w", err) } username, err := cliui.Prompt(inv, cliui.PromptOptions{ Text: "What " + pretty.Sprint(cliui.DefaultStyles.Field, "username") + " would you like?", Default: currentUser.Username, }) if errors.Is(err, cliui.Canceled) { return "", nil } if err != nil { return "", err } return username, nil } func promptFirstPassword(inv *serpent.Invocation) (string, error) { retry: password, err := cliui.Prompt(inv, cliui.PromptOptions{ Text: "Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password") + ":", Secret: true, Validate: func(s string) error { return userpassword.Validate(s) }, }) if err != nil { return "", xerrors.Errorf("specify password prompt: %w", err) } confirm, err := cliui.Prompt(inv, cliui.PromptOptions{ Text: "Confirm " + pretty.Sprint(cliui.DefaultStyles.Field, "password") + ":", Secret: true, Validate: cliui.ValidateNotEmpty, }) if err != nil { return "", xerrors.Errorf("confirm password prompt: %w", err) } if confirm != password { _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Error, "Passwords do not match")) goto retry } return password, nil } func (r *RootCmd) loginWithPassword( inv *serpent.Invocation, client *codersdk.Client, email, password string, ) error { resp, err := client.LoginWithPassword(inv.Context(), codersdk.LoginWithPasswordRequest{ Email: email, Password: password, }) if err != nil { return xerrors.Errorf("login with password: %w", err) } sessionToken := resp.SessionToken config := r.createConfig() err = config.Session().Write(sessionToken) if err != nil { return xerrors.Errorf("write session token: %w", err) } client.SetSessionToken(sessionToken) // Nice side-effect: validates the token. u, err := client.User(inv.Context(), "me") if err != nil { return xerrors.Errorf("get user: %w", err) } _, _ = fmt.Fprintf( inv.Stdout, "Welcome to Coder, %s! You're authenticated.", pretty.Sprint(cliui.DefaultStyles.Keyword, u.Username), ) return nil } func (r *RootCmd) login() *serpent.Command { const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL" var ( email string username string password string trial bool useTokenForSession bool ) cmd := &serpent.Command{ Use: "login []", Short: "Authenticate with Coder deployment", Middleware: serpent.RequireRangeArgs(0, 1), Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() rawURL := "" var urlSource string if len(inv.Args) == 0 { rawURL = r.clientURL.String() urlSource = "flag" if rawURL != "" && rawURL == inv.Environ.Get(envURL) { urlSource = "environment" } } else { rawURL = inv.Args[0] urlSource = "argument" } if url, err := r.createConfig().URL().Read(); rawURL == "" && err == nil { urlSource = "config" rawURL = url } if rawURL == "" { return xerrors.Errorf("no url argument provided") } if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { scheme := "https" if strings.HasPrefix(rawURL, "localhost") { scheme = "http" } rawURL = fmt.Sprintf("%s://%s", scheme, rawURL) } serverURL, err := url.Parse(rawURL) if err != nil { return xerrors.Errorf("parse raw url %q: %w", rawURL, err) } // Default to HTTPs. Enables simple URLs like: master.cdr.dev if serverURL.Scheme == "" { serverURL.Scheme = "https" } client, err := r.createUnauthenticatedClient(ctx, serverURL, inv) if err != nil { return err } hasFirstUser, err := client.HasFirstUser(ctx) if err != nil { return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err) } _, _ = fmt.Fprintf(inv.Stdout, "Attempting to authenticate with %s URL: '%s'\n", urlSource, serverURL) if !hasFirstUser { _, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n") if username == "" { if !isTTY(inv) { return xerrors.New("the initial user cannot be created in non-interactive mode. use the API") } _, err := cliui.Prompt(inv, cliui.PromptOptions{ Text: "Would you like to create the first user?", Default: cliui.ConfirmYes, IsConfirm: true, }) if err != nil { return err } username, err = promptFirstUsername(inv) if err != nil { return err } } if email == "" { email, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "What's your " + pretty.Sprint(cliui.DefaultStyles.Field, "email") + "?", Validate: func(s string) error { err := validator.New().Var(s, "email") if err != nil { return xerrors.New("That's not a valid email address!") } return err }, }) if err != nil { return err } } if password == "" { password, err = promptFirstPassword(inv) if err != nil { return err } } if !inv.ParsedFlags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" { v, _ := cliui.Prompt(inv, cliui.PromptOptions{ Text: "Start a 30-day trial of Enterprise?", IsConfirm: true, Default: "yes", }) trial = v == "yes" || v == "y" } _, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ Email: email, Username: username, Password: password, Trial: trial, }) if err != nil { return xerrors.Errorf("create initial user: %w", err) } err := r.loginWithPassword(inv, client, email, password) if err != nil { return err } err = r.createConfig().URL().Write(serverURL.String()) if err != nil { return xerrors.Errorf("write server url: %w", err) } _, _ = fmt.Fprintf( inv.Stdout, "Get started by creating a template: %s\n", pretty.Sprint(cliui.DefaultStyles.Code, "coder templates init"), ) return nil } sessionToken, _ := inv.ParsedFlags().GetString(varToken) if sessionToken == "" { authURL := *serverURL // Don't use filepath.Join, we don't want to use the os separator // for a url. authURL.Path = path.Join(serverURL.Path, "/cli-auth") if err := openURL(inv, authURL.String()); err != nil { _, _ = fmt.Fprintf(inv.Stdout, "Open the following in your browser:\n\n\t%s\n\n", authURL.String()) } else { _, _ = fmt.Fprintf(inv.Stdout, "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String()) } sessionToken, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Paste your token here:", Secret: true, Validate: func(token string) error { client.SetSessionToken(token) _, err := client.User(ctx, codersdk.Me) if err != nil { return xerrors.New("That's not a valid token!") } return err }, }) if err != nil { return xerrors.Errorf("paste token prompt: %w", err) } } else if !useTokenForSession { // If a session token is provided on the cli, use it to generate // a new one. This is because the cli `--token` flag provides // a token for the command being invoked. We should not store // this token, and `/logout` should not delete it. // /login should generate a new token and store that. client.SetSessionToken(sessionToken) // Use CreateAPIKey over CreateToken because this is a session // key that should not show on the `tokens` page. This should // match the same behavior of the `/cli-auth` page for generating // a session token. key, err := client.CreateAPIKey(ctx, "me") if err != nil { return xerrors.Errorf("create api key: %w", err) } sessionToken = key.Key } // Login to get user data - verify it is OK before persisting client.SetSessionToken(sessionToken) resp, err := client.User(ctx, codersdk.Me) if err != nil { return xerrors.Errorf("get user: %w", err) } config := r.createConfig() err = config.Session().Write(sessionToken) if err != nil { return xerrors.Errorf("write session token: %w", err) } err = config.URL().Write(serverURL.String()) if err != nil { return xerrors.Errorf("write server url: %w", err) } _, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n", pretty.Sprint(cliui.DefaultStyles.Keyword, resp.Username)) return nil }, } cmd.Options = serpent.OptionSet{ { Flag: "first-user-email", Env: "CODER_FIRST_USER_EMAIL", Description: "Specifies an email address to use if creating the first user for the deployment.", Value: serpent.StringOf(&email), }, { Flag: "first-user-username", Env: "CODER_FIRST_USER_USERNAME", Description: "Specifies a username to use if creating the first user for the deployment.", Value: serpent.StringOf(&username), }, { Flag: "first-user-password", Env: "CODER_FIRST_USER_PASSWORD", Description: "Specifies a password to use if creating the first user for the deployment.", Value: serpent.StringOf(&password), }, { Flag: "first-user-trial", Env: firstUserTrialEnv, Description: "Specifies whether a trial license should be provisioned for the Coder deployment or not.", Value: serpent.BoolOf(&trial), }, { Flag: "use-token-as-session", Description: "By default, the CLI will generate a new session token when logging in. This flag will instead use the provided token as the session token.", Value: serpent.BoolOf(&useTokenForSession), }, } return cmd } // isWSL determines if coder-cli is running within Windows Subsystem for Linux func isWSL() (bool, error) { if runtime.GOOS == goosDarwin || runtime.GOOS == goosWindows { return false, nil } data, err := os.ReadFile("/proc/version") if err != nil { return false, xerrors.Errorf("read /proc/version: %w", err) } return strings.Contains(strings.ToLower(string(data)), "microsoft"), nil } // openURL opens the provided URL via user's default browser func openURL(inv *serpent.Invocation, urlToOpen string) error { noOpen, err := inv.ParsedFlags().GetBool(varNoOpen) if err != nil { panic(err) } if noOpen { return xerrors.New("opening is blocked") } wsl, err := isWSL() if err != nil { return xerrors.Errorf("test running Windows Subsystem for Linux: %w", err) } if wsl { // #nosec return exec.Command("cmd.exe", "/c", "start", strings.ReplaceAll(urlToOpen, "&", "^&")).Start() } browserEnv := os.Getenv("BROWSER") if browserEnv != "" { browserSh := fmt.Sprintf("%s '%s'", browserEnv, urlToOpen) cmd := exec.CommandContext(inv.Context(), "sh", "-c", browserSh) out, err := cmd.CombinedOutput() if err != nil { return xerrors.Errorf("failed to run %v (out: %q): %w", cmd.Args, out, err) } return nil } return browser.OpenURL(urlToOpen) }