Skip to content

feat: Login via CLI #298

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Feb 18, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 100 additions & 8 deletions cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,29 @@ package cli

import (
"fmt"
"io/ioutil"
"net/url"
"os/exec"
"os/user"
"runtime"
"strings"

"github.com/fatih/color"
"github.com/go-playground/validator/v10"
"github.com/manifoldco/promptui"
"github.com/pkg/browser"
"github.com/spf13/cobra"
"golang.org/x/xerrors"

"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
)

const (
goosWindows = "windows"
goosDarwin = "darwin"
)

func login() *cobra.Command {
return &cobra.Command{
Use: "login <url>",
Expand Down Expand Up @@ -116,21 +125,104 @@ func login() *cobra.Command {
if err != nil {
return xerrors.Errorf("login with password: %w", err)
}
config := createConfig(cmd)
err = config.Session().Write(resp.SessionToken)
if err != nil {
return xerrors.Errorf("write session token: %w", err)
}
err = config.URL().Write(serverURL.String())

err = saveSessionToken(cmd, client, resp.SessionToken, serverURL)
if err != nil {
return xerrors.Errorf("write server url: %w", err)
return xerrors.Errorf("save session token: %w", err)
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(username))
return nil
}

authURL := *serverURL
authURL.Path = serverURL.Path + "/cli-auth"
if err := openURL(authURL.String()); err != nil {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Open the following in your browser:\n\n\t%s\n\n", authURL.String())
} else {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
}

apiKey, err := prompt(cmd, &promptui.Prompt{
Label: "Paste your token here:",
Validate: func(token string) error {
client.SessionToken = token
_, err := client.User(cmd.Context(), "me")
if err != nil {
return xerrors.New("That's not a valid token!")
}
return err
},
})
if err != nil {
return xerrors.Errorf("specify email prompt: %w", err)
}

err = saveSessionToken(cmd, client, apiKey, serverURL)
if err != nil {
return xerrors.Errorf("save session token after login: %w", err)
}

return nil
},
}
}

func saveSessionToken(cmd *cobra.Command, client *codersdk.Client, sessionToken string, serverURL *url.URL) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm hesitant to rip this into a distinct function. We already validate the "me" user request above, so feels like that should be reused.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alternative is to duplicate the persistence code both in the 'create initial user' and 'login' flows - inlined this function in 13696a7

Another thing we could consider is bringing the "me" check above (since it's only required for initial login), and factor out the common save-session-token-and-url logic into this function. Happy with whichever you prefer.

// Login to get user data - verify it is OK before persisting
client.SessionToken = sessionToken
resp, err := client.User(cmd.Context(), "me")
if err != nil {
return xerrors.Errorf("get user: %w", err)
}

config := createConfig(cmd)
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(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(resp.Username))
return nil
}

// 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 := ioutil.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(urlToOpen string) error {
var cmd string
var args []string

wsl, err := isWSL()
if err != nil {
return xerrors.Errorf("test running Windows Subsystem for Linux: %w", err)
}

if wsl {
cmd = "cmd.exe"
args = []string{"/c", "start"}
urlToOpen = strings.ReplaceAll(urlToOpen, "&", "^&")
args = append(args, urlToOpen)
return exec.Command(cmd, args...).Start()
}

// 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
browser.Stderr = ioutil.Discard
browser.Stdout = ioutil.Discard
return browser.OpenURL(urlToOpen)
}
62 changes: 61 additions & 1 deletion cli/login_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package cli_test

import (
"context"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/console"
"github.com/stretchr/testify/require"
)

func TestLogin(t *testing.T) {
Expand Down Expand Up @@ -50,4 +53,61 @@ func TestLogin(t *testing.T) {
_, err := cons.ExpectString("Welcome to Coder")
require.NoError(t, err)
})

t.Run("ExistingUserValidTokenTTY", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{
Username: "test-user",
Email: "test-user@coder.com",
Organization: "acme-corp",
Password: "password",
})
require.NoError(t, err)
token, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: "test-user@coder.com",
Password: "password",
})
require.NoError(t, err)

root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
cons := console.New(t, root)
go func() {
err := root.Execute()
require.NoError(t, err)
}()

_, err = cons.ExpectString("Paste your token here:")
require.NoError(t, err)
_, err = cons.SendLine(token.SessionToken)
require.NoError(t, err)
_, err = cons.ExpectString("Welcome to Coder")
require.NoError(t, err)
})

t.Run("ExistingUserInvalidTokenTTY", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{
Username: "test-user",
Email: "test-user@coder.com",
Organization: "acme-corp",
Password: "password",
})
require.NoError(t, err)

root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
cons := console.New(t, root)
go func() {
err := root.Execute()
require.Error(t, err)
}()

_, err = cons.ExpectString("Paste your token here:")
require.NoError(t, err)
_, err = cons.SendLine("an-invalid-token")
require.NoError(t, err)
_, err = cons.ExpectString("That's not a valid token!")
require.NoError(t, err)
})
}
3 changes: 2 additions & 1 deletion cli/workspacecreate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ package cli_test
import (
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/console"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/stretchr/testify/require"
)

func TestWorkspaceCreate(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ func New(options *Options) http.Handler {
})
r.Post("/login", api.postLogin)
r.Post("/logout", api.postLogout)
r.Route("/api-keys", func(r chi.Router) {
r.Use(
httpmw.ExtractAPIKey(options.Database, nil),
)
r.Post("/", api.postAPIKey)
})

// Used for setup.
r.Get("/user", api.user)
r.Post("/user", api.postUser)
Expand Down
3 changes: 2 additions & 1 deletion coderd/projectimport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import (
"net/http"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/stretchr/testify/require"
)

func TestPostProjectImportByOrganization(t *testing.T) {
Expand Down
42 changes: 42 additions & 0 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ type LoginWithPasswordResponse struct {
SessionToken string `json:"session_token" validate:"required"`
}

// GenerateAPIKeyResponse contains an API key for a user.
type GenerateAPIKeyResponse struct {
Key string `json:"key"`
}

// Returns whether the initial user has been created or not.
func (api *api) user(rw http.ResponseWriter, r *http.Request) {
userCount, err := api.Database.GetUserCount(r.Context())
Expand Down Expand Up @@ -312,6 +317,43 @@ func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) {
})
}

// Creates a new API key, used for logging in via the CLI
func (api *api) postAPIKey(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
userID := apiKey.UserID

keyID, keySecret, err := generateAPIKeyIDSecret()
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("generate api key parts: %s", err.Error()),
})
return
}
hashed := sha256.Sum256([]byte(keySecret))

_, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ID: keyID,
UserID: userID,
ExpiresAt: database.Now().AddDate(1, 0, 0), // Expire after 1 year (same as v1)
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
HashedSecret: hashed[:],
LoginType: database.LoginTypeBuiltIn,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert api key: %s", err.Error()),
})
return
}

// This format is consumed by the APIKey middleware.
generatedAPIKey := fmt.Sprintf("%s-%s", keyID, keySecret)

render.Status(r, http.StatusCreated)
render.JSON(rw, r, GenerateAPIKeyResponse{Key: generatedAPIKey})
}

// Clear the user's session cookie
func (*api) postLogout(rw http.ResponseWriter, r *http.Request) {
// Get a blank token cookie
Expand Down
27 changes: 27 additions & 0 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@ func TestOrganizationsByUser(t *testing.T) {
require.Len(t, orgs, 1)
}

func TestPostAPIKey(t *testing.T) {
t.Parallel()
t.Run("InvalidUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
_ = coderdtest.CreateInitialUser(t, client)

// Clear session token
client.SessionToken = ""
// ...and request an API key
_, err := client.CreateAPIKey(context.Background())
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
})

t.Run("Success", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
_ = coderdtest.CreateInitialUser(t, client)
apiKey, err := client.CreateAPIKey(context.Background())
require.NotNil(t, apiKey)
require.GreaterOrEqual(t, len(apiKey.Key), 2)
require.NoError(t, err)
})
}

func TestPostLogin(t *testing.T) {
t.Parallel()
t.Run("InvalidUser", func(t *testing.T) {
Expand Down
5 changes: 3 additions & 2 deletions codersdk/projectimport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import (
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/require"

"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)

func TestCreateProjectImportJob(t *testing.T) {
Expand Down
14 changes: 14 additions & 0 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ func (c *Client) CreateUser(ctx context.Context, req coderd.CreateUserRequest) (
return user, json.NewDecoder(res.Body).Decode(&user)
}

// CreateAPIKey calls the /api-key API
func (c *Client) CreateAPIKey(ctx context.Context) (*coderd.GenerateAPIKeyResponse, error) {
res, err := c.request(ctx, http.MethodPost, "/api/v2/api-keys", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode > http.StatusCreated {
return nil, readBodyAsError(res)
}
apiKey := &coderd.GenerateAPIKeyResponse{}
return apiKey, json.NewDecoder(res.Body).Decode(apiKey)
}

// LoginWithPassword creates a session token authenticating with an email and password.
// Call `SetSessionToken()` to apply the newly acquired token to the client.
func (c *Client) LoginWithPassword(ctx context.Context, req coderd.LoginWithPasswordRequest) (coderd.LoginWithPasswordResponse, error) {
Expand Down
Loading