coder/cli/login.go

421 lines
12 KiB
Go

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 [<url>]",
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)
}