mirror of https://github.com/coder/coder.git
421 lines
12 KiB
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)
|
|
}
|