diff --git a/cli/login.go b/cli/login.go index 5910b5846ddcd..9658edb835c2e 100644 --- a/cli/login.go +++ b/cli/login.go @@ -2,13 +2,17 @@ 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" @@ -16,6 +20,21 @@ import ( "github.com/coder/coder/codersdk" ) +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 = ioutil.Discard + browser.Stdout = ioutil.Discard +} + func login() *cobra.Command { return &cobra.Command{ Use: "login ", @@ -116,8 +135,10 @@ func login() *cobra.Command { if err != nil { return xerrors.Errorf("login with password: %w", err) } + + sessionToken := resp.SessionToken config := createConfig(cmd) - err = config.Session().Write(resp.SessionToken) + err = config.Session().Write(sessionToken) if err != nil { return xerrors.Errorf("write session token: %w", err) } @@ -130,7 +151,82 @@ func login() *cobra.Command { 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()) + } + + sessionToken, err := prompt(cmd, &promptui.Prompt{ + Label: "Paste your token here:", + Mask: '*', + 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("paste token prompt: %w", err) + } + + // 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() + } + + return browser.OpenURL(urlToOpen) +} diff --git a/cli/login_test.go b/cli/login_test.go index 24caf18e1aa3f..e9f3cc13ecf3f 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -1,11 +1,13 @@ 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/pty/ptytest" ) @@ -50,4 +52,60 @@ func TestLogin(t *testing.T) { } pty.ExpectMatch("Welcome to Coder") }) + + 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") + pty := ptytest.New(t) + root.SetIn(pty.Input()) + root.SetOut(pty.Output()) + go func() { + err := root.Execute() + require.NoError(t, err) + }() + + pty.ExpectMatch("Paste your token here:") + pty.WriteLine(token.SessionToken) + pty.ExpectMatch("Welcome to Coder") + }) + + 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") + pty := ptytest.New(t) + root.SetIn(pty.Input()) + root.SetOut(pty.Output()) + go func() { + err := root.Execute() + // An error is expected in this case, since the login wasn't successful: + require.Error(t, err) + }() + + pty.ExpectMatch("Paste your token here:") + pty.WriteLine("an-invalid-token") + pty.ExpectMatch("That's not a valid token!") + }) } diff --git a/coderd/coderd.go b/coderd/coderd.go index 9669dbf92c2e1..1213b04aa0a86 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -36,6 +36,7 @@ func New(options *Options) http.Handler { }) r.Post("/login", api.postLogin) r.Post("/logout", api.postLogout) + // Used for setup. r.Get("/user", api.user) r.Post("/user", api.postUser) @@ -44,10 +45,12 @@ func New(options *Options) http.Handler { httpmw.ExtractAPIKey(options.Database, nil), ) r.Post("/", api.postUsers) - r.Group(func(r chi.Router) { + + r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) - r.Get("/{user}", api.userByName) - r.Get("/{user}/organizations", api.organizationsByUser) + r.Get("/", api.userByName) + r.Get("/organizations", api.organizationsByUser) + r.Post("/keys", api.postKeyForUser) }) }) r.Route("/projects", func(r chi.Router) { diff --git a/coderd/users.go b/coderd/users.go index ab6328fbe1984..a660f31fdfa94 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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()) @@ -312,6 +317,50 @@ func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) { }) } +// Creates a new session key, used for logging in via the CLI +func (api *api) postKeyForUser(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + apiKey := httpmw.APIKey(r) + + if user.ID != apiKey.UserID { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: "Keys can only be generated for the authenticated user", + }) + return + } + + 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: apiKey.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 diff --git a/coderd/users_test.go b/coderd/users_test.go index 8ef1464856f75..3a3a5ee8eac76 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -119,6 +119,33 @@ func TestOrganizationsByUser(t *testing.T) { require.Len(t, orgs, 1) } +func TestPostKey(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) { diff --git a/codersdk/users.go b/codersdk/users.go index b17b5a9931e6e..08b152f9cf8d0 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -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/users/me/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) { diff --git a/go.mod b/go.mod index e224d221c55bc..5c13f4423b4e4 100644 --- a/go.mod +++ b/go.mod @@ -115,6 +115,7 @@ require ( github.com/pion/stun v0.3.5 // indirect github.com/pion/turn/v2 v2.0.6 // indirect github.com/pion/udp v0.1.1 // indirect + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sirupsen/logrus v1.8.1 // indirect diff --git a/go.sum b/go.sum index 416fe1da0f69d..049f05866af57 100644 --- a/go.sum +++ b/go.sum @@ -1062,6 +1062,7 @@ github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M github.com/pion/webrtc/v3 v3.1.23 h1:suyNiF9o2/6SBsyWA1UweraUWYkaHCNJdt/16b61I5w= github.com/pion/webrtc/v3 v3.1.23/go.mod h1:L5S/oAhL0Fzt/rnftVQRrP80/j5jygY7XRZzWwFx6P4= github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/site/api.ts b/site/api.ts index 0a11659ed24d7..5339ccd15d851 100644 --- a/site/api.ts +++ b/site/api.ts @@ -139,3 +139,16 @@ export const logout = async (): Promise => { return } + +export const getApiKey = async (): Promise<{ key: string }> => { + const response = await fetch("/api/v2/users/me/keys", { + method: "POST", + }) + + if (!response.ok) { + const body = await response.json() + throw new Error(body.message) + } + + return await response.json() +} diff --git a/site/components/SignIn/CliAuthToken.stories.tsx b/site/components/SignIn/CliAuthToken.stories.tsx new file mode 100644 index 0000000000000..b9d8e5baa9a47 --- /dev/null +++ b/site/components/SignIn/CliAuthToken.stories.tsx @@ -0,0 +1,16 @@ +import { Story } from "@storybook/react" +import React from "react" +import { CliAuthToken, CliAuthTokenProps } from "./CliAuthToken" + +export default { + title: "SignIn/CliAuthToken", + component: CliAuthToken, + argTypes: { + sessionToken: { control: "text", defaultValue: "some-session-token" }, + }, +} + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = {} diff --git a/site/components/SignIn/CliAuthToken.test.tsx b/site/components/SignIn/CliAuthToken.test.tsx new file mode 100644 index 0000000000000..d17fdbcce88ea --- /dev/null +++ b/site/components/SignIn/CliAuthToken.test.tsx @@ -0,0 +1,16 @@ +import React from "react" +import { screen } from "@testing-library/react" +import { render } from "../../test_helpers" + +import { CliAuthToken } from "./CliAuthToken" + +describe("CliAuthToken", () => { + it("renders content", async () => { + // When + render() + + // Then + await screen.findByText("Session Token") + await screen.findByText("test-token") + }) +}) diff --git a/site/components/SignIn/CliAuthToken.tsx b/site/components/SignIn/CliAuthToken.tsx new file mode 100644 index 0000000000000..a6d0886e60ebf --- /dev/null +++ b/site/components/SignIn/CliAuthToken.tsx @@ -0,0 +1,29 @@ +import Paper from "@material-ui/core/Paper" +import Typography from "@material-ui/core/Typography" +import { makeStyles } from "@material-ui/core/styles" +import React from "react" +import { CodeExample } from "../CodeExample" + +export interface CliAuthTokenProps { + sessionToken: string +} + +export const CliAuthToken: React.FC = ({ sessionToken }) => { + const styles = useStyles() + return ( + + Session Token + + + ) +} + +const useStyles = makeStyles((theme) => ({ + title: { + marginBottom: theme.spacing(2), + }, + container: { + maxWidth: "680px", + padding: theme.spacing(2), + }, +})) diff --git a/site/components/SignIn/index.tsx b/site/components/SignIn/index.tsx index 6ea6f3de7dd2a..066b58003c67c 100644 --- a/site/components/SignIn/index.tsx +++ b/site/components/SignIn/index.tsx @@ -1 +1,2 @@ +export * from "./CliAuthToken" export * from "./SignInForm" diff --git a/site/pages/cli-auth.tsx b/site/pages/cli-auth.tsx new file mode 100644 index 0000000000000..a31c682d93090 --- /dev/null +++ b/site/pages/cli-auth.tsx @@ -0,0 +1,44 @@ +import { makeStyles } from "@material-ui/core/styles" +import React, { useEffect, useState } from "react" +import { getApiKey } from "../api" +import { CliAuthToken } from "../components/SignIn" + +import { FullScreenLoader } from "../components/Loader/FullScreenLoader" +import { useUser } from "../contexts/UserContext" + +const CliAuthenticationPage: React.FC = () => { + const { me } = useUser(true) + const styles = useStyles() + + const [apiKey, setApiKey] = useState(null) + + useEffect(() => { + if (me?.id) { + void getApiKey().then(({ key }) => { + setApiKey(key) + }) + } + }, [me?.id]) + + if (!apiKey) { + return + } + + return ( +
+ +
+ ) +} + +const useStyles = makeStyles(() => ({ + root: { + width: "100vw", + height: "100vh", + display: "flex", + justifyContent: "center", + alignItems: "center", + }, +})) + +export default CliAuthenticationPage