From 6c50b85a129df76fb320f588d20458de42b09f86 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 16 Feb 2022 01:09:15 +0000 Subject: [PATCH 01/17] Create initial route for generating an API key --- coderd/coderd.go | 7 +++++++ coderd/users.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index 9669dbf92c2e1..6409aa3f55c20 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) diff --git a/coderd/users.go b/coderd/users.go index ab6328fbe1984..87a46aeca7366 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,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 From fe55c73edb5398b1e3558a7a33a8ae3f7798687e Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 16 Feb 2022 01:44:07 +0000 Subject: [PATCH 02/17] Add cli-auth page --- site/api.ts | 13 ++++++++++ site/pages/cli-auth.tsx | 56 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 site/pages/cli-auth.tsx diff --git a/site/api.ts b/site/api.ts index 0a11659ed24d7..eb37583d262e7 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/api-keys", { + method: "POST", + }) + + if (!response.ok) { + const body = await response.json() + throw new Error(body.message) + } + + return await response.json() +} diff --git a/site/pages/cli-auth.tsx b/site/pages/cli-auth.tsx new file mode 100644 index 0000000000000..2c796fc0f7e53 --- /dev/null +++ b/site/pages/cli-auth.tsx @@ -0,0 +1,56 @@ +import { Typography } from "@material-ui/core" +import Paper from "@material-ui/core/Paper" +import { makeStyles } from "@material-ui/core/styles" +import React, { useEffect, useState } from "react" +import { getApiKey } from "../api" +import { CodeExample } from "../components/CodeExample" + +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) { + getApiKey().then(({ key }) => { + setApiKey(key) + }) + } + }, [me?.id]) + + if (!apiKey) { + return + } + + return ( +
+ + Session Token + + +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + width: "100vh", + height: "100vw", + display: "flex", + justifyContent: "center", + alignItems: "center", + }, + title: { + marginBottom: theme.spacing(2), + }, + container: { + maxWidth: "680px", + padding: theme.spacing(2), + }, +})) + +export default CliAuthenticationPage From 3bb3164dd9d028d8489d5ce98a6e06e6afbd631d Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 16 Feb 2022 02:15:16 +0000 Subject: [PATCH 03/17] Fix lint warnings --- site/pages/cli-auth.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/pages/cli-auth.tsx b/site/pages/cli-auth.tsx index 2c796fc0f7e53..0df7147d9c4ff 100644 --- a/site/pages/cli-auth.tsx +++ b/site/pages/cli-auth.tsx @@ -1,5 +1,5 @@ -import { Typography } from "@material-ui/core" import Paper from "@material-ui/core/Paper" +import Typography from "@material-ui/core/Typography" import { makeStyles } from "@material-ui/core/styles" import React, { useEffect, useState } from "react" import { getApiKey } from "../api" @@ -16,7 +16,7 @@ const CliAuthenticationPage: React.FC = () => { useEffect(() => { if (me?.id) { - getApiKey().then(({ key }) => { + void getApiKey().then(({ key }) => { setApiKey(key) }) } From 9ec938ec7bd58d4c50f9d05955c44c5be341bed8 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Wed, 16 Feb 2022 23:12:31 +0000 Subject: [PATCH 04/17] Add login prompt for non-first-time-user case --- cli/login.go | 104 +++++++++++++++++++++++++++++++++++++++++++++++---- go.mod | 1 + go.sum | 1 + 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/cli/login.go b/cli/login.go index 5910b5846ddcd..538e73fd5dcff 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,12 @@ import ( "github.com/coder/coder/codersdk" ) +const ( + goosWindows = "windows" + goosLinux = "linux" + goosDarwin = "darwin" +) + func login() *cobra.Command { return &cobra.Command{ Use: "login ", @@ -116,21 +126,99 @@ 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 { + // 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: ", 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(url 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"} + url = strings.ReplaceAll(url, "&", "^&") + args = append(args, url) + return exec.Command(cmd, args...).Start() + } + + return browser.OpenURL(url) +} diff --git a/go.mod b/go.mod index 290c7d3758b85..3ad5bd3f30687 100644 --- a/go.mod +++ b/go.mod @@ -113,6 +113,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 8e4c8c18401ae..5ffc19a3f44ea 100644 --- a/go.sum +++ b/go.sum @@ -1060,6 +1060,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= From 87fc9e2b0cd5624d8d253ee0aaf75682719b2e22 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 00:08:05 +0000 Subject: [PATCH 05/17] Add test case for login --- cli/clitest/clitest_test.go | 5 +-- cli/login_test.go | 61 ++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/cli/clitest/clitest_test.go b/cli/clitest/clitest_test.go index f5be5a45db12c..108addc0d91ce 100644 --- a/cli/clitest/clitest_test.go +++ b/cli/clitest/clitest_test.go @@ -3,11 +3,12 @@ package clitest_test import ( "testing" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/expect" - "github.com/stretchr/testify/require" - "go.uber.org/goleak" ) func TestMain(m *testing.M) { diff --git a/cli/login_test.go b/cli/login_test.go index 43859ba56199c..cac19400632df 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -1,11 +1,13 @@ package cli_test import ( + "context" "testing" "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/expect" + "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/expect" "github.com/stretchr/testify/require" ) @@ -50,4 +52,61 @@ func TestLogin(t *testing.T) { _, err := console.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") + console := expect.NewTestConsole(t, root) + go func() { + err := root.Execute() + require.NoError(t, err) + }() + + _, err = console.ExpectString("Paste your token here:") + require.NoError(t, err) + _, err = console.SendLine(token.SessionToken) + require.NoError(t, err) + _, err = console.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") + console := expect.NewTestConsole(t, root) + go func() { + err := root.Execute() + require.Error(t, err) + }() + + _, err = console.ExpectString("Paste your token here:") + require.NoError(t, err) + _, err = console.SendLine("an-invalid-token") + require.NoError(t, err) + _, err = console.ExpectString("That's not a valid token!") + require.NoError(t, err) + }) } From 6607a5ad55adf9d6c622ef5afb8046e7851409c7 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 00:15:45 +0000 Subject: [PATCH 06/17] Factor out CliAuth token component --- site/components/SignIn/CliAuthToken.tsx | 29 +++++++++++++++++++++++++ site/components/SignIn/index.tsx | 1 + site/pages/cli-auth.tsx | 20 ++++------------- 3 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 site/components/SignIn/CliAuthToken.tsx 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 index 0df7147d9c4ff..1932c0a4ae8c0 100644 --- a/site/pages/cli-auth.tsx +++ b/site/pages/cli-auth.tsx @@ -1,9 +1,7 @@ -import Paper from "@material-ui/core/Paper" -import Typography from "@material-ui/core/Typography" import { makeStyles } from "@material-ui/core/styles" import React, { useEffect, useState } from "react" import { getApiKey } from "../api" -import { CodeExample } from "../components/CodeExample" +import { CliAuthToken } from "../components/SignIn" import { FullScreenLoader } from "../components/Loader/FullScreenLoader" import { useUser } from "../contexts/UserContext" @@ -28,29 +26,19 @@ const CliAuthenticationPage: React.FC = () => { return (
- - Session Token - - +
) } const useStyles = makeStyles((theme) => ({ root: { - width: "100vh", - height: "100vw", + width: "100vw", + height: "100vh", display: "flex", justifyContent: "center", alignItems: "center", }, - title: { - marginBottom: theme.spacing(2), - }, - container: { - maxWidth: "680px", - padding: theme.spacing(2), - }, })) export default CliAuthenticationPage From 0e323ce2dbd61094635f618fae15449d93155132 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 00:20:02 +0000 Subject: [PATCH 07/17] Add storybook for CliAuthToken --- site/components/SignIn/CliAuthToken.stories.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 site/components/SignIn/CliAuthToken.stories.tsx 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 = {} From 9cc7e942388da63aac0cb334c2b830bf8a6384f8 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 00:45:45 +0000 Subject: [PATCH 08/17] Add test case for CliAuthToken --- site/components/SignIn/CliAuthToken.test.tsx | 16 ++++++++++++++++ site/pages/cli-auth.tsx | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 site/components/SignIn/CliAuthToken.test.tsx 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/pages/cli-auth.tsx b/site/pages/cli-auth.tsx index 1932c0a4ae8c0..a31c682d93090 100644 --- a/site/pages/cli-auth.tsx +++ b/site/pages/cli-auth.tsx @@ -31,7 +31,7 @@ const CliAuthenticationPage: React.FC = () => { ) } -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(() => ({ root: { width: "100vw", height: "100vh", From 7774e29341d790d2858c1a08dec9b3fea273bfd7 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 00:56:57 +0000 Subject: [PATCH 09/17] Add codersdk function + test --- coderd/users_test.go | 27 +++++++++++++++++++++++++++ codersdk/users.go | 14 ++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/coderd/users_test.go b/coderd/users_test.go index 8ef1464856f75..0c7af6183eb8a 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 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) { diff --git a/codersdk/users.go b/codersdk/users.go index b17b5a9931e6e..bf90fb5c1f6a9 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, fmt.Sprintf("/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) { From 8ea29203a78b7b4d2e75b52dd44c0e7ab505bf42 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 01:01:19 +0000 Subject: [PATCH 10/17] Fix lint issues --- cli/login.go | 11 +++++------ cli/login_test.go | 3 ++- cli/workspacecreate_test.go | 3 ++- coderd/coderd.go | 2 +- coderd/projectimport_test.go | 3 ++- coderd/users.go | 6 +++--- coderd/users_test.go | 4 ++-- codersdk/projectimport_test.go | 5 +++-- codersdk/users.go | 6 +++--- 9 files changed, 23 insertions(+), 20 deletions(-) diff --git a/cli/login.go b/cli/login.go index 538e73fd5dcff..4068bb97b8fbe 100644 --- a/cli/login.go +++ b/cli/login.go @@ -22,7 +22,6 @@ import ( const ( goosWindows = "windows" - goosLinux = "linux" goosDarwin = "darwin" ) @@ -173,7 +172,7 @@ func saveSessionToken(cmd *cobra.Command, client *codersdk.Client, sessionToken client.SessionToken = sessionToken resp, err := client.User(cmd.Context(), "me") if err != nil { - return xerrors.Errorf("get user: ", err) + return xerrors.Errorf("get user: %w", err) } config := createConfig(cmd) @@ -203,7 +202,7 @@ func isWSL() (bool, error) { } // openURL opens the provided URL via user's default browser -func openURL(url string) error { +func openURL(urlToOpen string) error { var cmd string var args []string @@ -215,10 +214,10 @@ func openURL(url string) error { if wsl { cmd = "cmd.exe" args = []string{"/c", "start"} - url = strings.ReplaceAll(url, "&", "^&") - args = append(args, url) + urlToOpen = strings.ReplaceAll(urlToOpen, "&", "^&") + args = append(args, urlToOpen) return exec.Command(cmd, args...).Start() } - return browser.OpenURL(url) + return browser.OpenURL(urlToOpen) } diff --git a/cli/login_test.go b/cli/login_test.go index 94647419c4390..eb5063d57b93f 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -4,11 +4,12 @@ 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) { diff --git a/cli/workspacecreate_test.go b/cli/workspacecreate_test.go index 306caa65c4b0c..7513a49bc2f98 100644 --- a/cli/workspacecreate_test.go +++ b/cli/workspacecreate_test.go @@ -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) { diff --git a/coderd/coderd.go b/coderd/coderd.go index 6409aa3f55c20..7211c555bccea 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -40,7 +40,7 @@ func New(options *Options) http.Handler { r.Use( httpmw.ExtractAPIKey(options.Database, nil), ) - r.Post("/", api.postApiKey) + r.Post("/", api.postAPIKey) }) // Used for setup. diff --git a/coderd/projectimport_test.go b/coderd/projectimport_test.go index 06140190f51d5..b9df691233576 100644 --- a/coderd/projectimport_test.go +++ b/coderd/projectimport_test.go @@ -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) { diff --git a/coderd/users.go b/coderd/users.go index 87a46aeca7366..c4063ae5e69a7 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -318,7 +318,7 @@ 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) { +func (api *api) postAPIKey(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) userID := apiKey.UserID @@ -348,10 +348,10 @@ func (api *api) postApiKey(rw http.ResponseWriter, r *http.Request) { } // This format is consumed by the APIKey middleware. - generatedApiKey := fmt.Sprintf("%s-%s", keyID, keySecret) + generatedAPIKey := fmt.Sprintf("%s-%s", keyID, keySecret) render.Status(r, http.StatusCreated) - render.JSON(rw, r, GenerateAPIKeyResponse{Key: generatedApiKey}) + render.JSON(rw, r, GenerateAPIKeyResponse{Key: generatedAPIKey}) } // Clear the user's session cookie diff --git a/coderd/users_test.go b/coderd/users_test.go index 0c7af6183eb8a..bc95f8a19a922 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -129,7 +129,7 @@ func TestPostAPIKey(t *testing.T) { // Clear session token client.SessionToken = "" // ...and request an API key - _, err := client.CreateApiKey(context.Background()) + _, err := client.CreateAPIKey(context.Background()) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) @@ -139,7 +139,7 @@ func TestPostAPIKey(t *testing.T) { t.Parallel() client := coderdtest.New(t) _ = coderdtest.CreateInitialUser(t, client) - apiKey, err := client.CreateApiKey(context.Background()) + apiKey, err := client.CreateAPIKey(context.Background()) require.NotNil(t, apiKey) require.GreaterOrEqual(t, len(apiKey.Key), 2) require.NoError(t, err) diff --git a/codersdk/projectimport_test.go b/codersdk/projectimport_test.go index 8cc6b28a23f6c..ccbe01345845a 100644 --- a/codersdk/projectimport_test.go +++ b/codersdk/projectimport_test.go @@ -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) { diff --git a/codersdk/users.go b/codersdk/users.go index bf90fb5c1f6a9..3bc66c764266c 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -56,9 +56,9 @@ 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, fmt.Sprintf("/api/v2/api-keys"), nil) +// 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 } From 669f2ca433c9536e87f5136b0ff3ff7f21e69526 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 01:57:39 +0000 Subject: [PATCH 11/17] Discard output of browser pkg --- cli/login.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cli/login.go b/cli/login.go index 4068bb97b8fbe..20dd46d71f234 100644 --- a/cli/login.go +++ b/cli/login.go @@ -219,5 +219,10 @@ func openURL(urlToOpen string) error { 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) } From 5ecba6da44254dea53768bad4573c29b015a0687 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 21:46:44 +0000 Subject: [PATCH 12/17] Add mask for session token prompt --- cli/login.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/login.go b/cli/login.go index 2d51a49f8b7fa..55b76e03c81bb 100644 --- a/cli/login.go +++ b/cli/login.go @@ -144,6 +144,7 @@ func login() *cobra.Command { apiKey, 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") From 13696a7a663f7edb332615fdf7b57136352b1fba Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 22:09:59 +0000 Subject: [PATCH 13/17] Inline saveSessionToken into both code paths --- cli/login.go | 47 +++++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/cli/login.go b/cli/login.go index 55b76e03c81bb..21d384d74c406 100644 --- a/cli/login.go +++ b/cli/login.go @@ -126,11 +126,18 @@ func login() *cobra.Command { return xerrors.Errorf("login with password: %w", err) } - err = saveSessionToken(cmd, client, resp.SessionToken, serverURL) + sessionToken := resp.SessionToken + config := createConfig(cmd) + err = config.Session().Write(sessionToken) if err != nil { - return xerrors.Errorf("save session token: %w", err) + 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(username)) return nil } @@ -142,7 +149,7 @@ func login() *cobra.Command { _, _ = 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{ + sessionToken, err := prompt(cmd, &promptui.Prompt{ Label: "Paste your token here:", Mask: '*', Validate: func(token string) error { @@ -158,35 +165,31 @@ func login() *cobra.Command { return xerrors.Errorf("paste token prompt: %w", err) } - err = saveSessionToken(cmd, client, apiKey, serverURL) + // 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("save session token after login: %w", err) + 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 }, } } func saveSessionToken(cmd *cobra.Command, client *codersdk.Client, sessionToken string, serverURL *url.URL) error { - // 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 } From dccb009ef3fa53c76ca7ac2f6fb3cfb9a0039897 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 22:14:29 +0000 Subject: [PATCH 14/17] Move setting browser.Stderr/Stdout to init --- cli/login.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cli/login.go b/cli/login.go index 21d384d74c406..a0cf765cb8d00 100644 --- a/cli/login.go +++ b/cli/login.go @@ -25,6 +25,16 @@ const ( 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 ", @@ -223,10 +233,5 @@ func openURL(urlToOpen string) error { 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) } From ca1a4583889e849f5b5f86bc8dc8a53004e57cfd Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 23:31:36 +0000 Subject: [PATCH 15/17] Change route /api-keys -> /users/{user}/keys --- coderd/coderd.go | 14 +++++--------- coderd/users.go | 15 +++++++++++---- coderd/users_test.go | 2 +- codersdk/users.go | 2 +- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 7211c555bccea..1213b04aa0a86 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -36,12 +36,6 @@ 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) @@ -51,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 c4063ae5e69a7..a660f31fdfa94 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -317,10 +317,17 @@ 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) { +// 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) - userID := apiKey.UserID + + 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 { @@ -333,7 +340,7 @@ func (api *api) postAPIKey(rw http.ResponseWriter, r *http.Request) { _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: keyID, - UserID: userID, + UserID: apiKey.UserID, ExpiresAt: database.Now().AddDate(1, 0, 0), // Expire after 1 year (same as v1) CreatedAt: database.Now(), UpdatedAt: database.Now(), diff --git a/coderd/users_test.go b/coderd/users_test.go index bc95f8a19a922..3a3a5ee8eac76 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -119,7 +119,7 @@ func TestOrganizationsByUser(t *testing.T) { require.Len(t, orgs, 1) } -func TestPostAPIKey(t *testing.T) { +func TestPostKey(t *testing.T) { t.Parallel() t.Run("InvalidUser", func(t *testing.T) { t.Parallel() diff --git a/codersdk/users.go b/codersdk/users.go index 3bc66c764266c..08b152f9cf8d0 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -58,7 +58,7 @@ func (c *Client) CreateUser(ctx context.Context, req coderd.CreateUserRequest) ( // 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) + res, err := c.request(ctx, http.MethodPost, "/api/v2/users/me/keys", nil) if err != nil { return nil, err } From b232d0c2c0c9d02b60575eec73e110c32916565e Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 17 Feb 2022 23:32:57 +0000 Subject: [PATCH 16/17] Remove leftover saveSessionToken func --- cli/login.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cli/login.go b/cli/login.go index a0cf765cb8d00..9658edb835c2e 100644 --- a/cli/login.go +++ b/cli/login.go @@ -198,11 +198,6 @@ func login() *cobra.Command { } } -func saveSessionToken(cmd *cobra.Command, client *codersdk.Client, sessionToken string, serverURL *url.URL) error { - - 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 { From 0d829b332840523ce04fcf45712391ddf33c1719 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Fri, 18 Feb 2022 03:41:16 +0000 Subject: [PATCH 17/17] Update route in UI for getting new token --- site/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/api.ts b/site/api.ts index eb37583d262e7..5339ccd15d851 100644 --- a/site/api.ts +++ b/site/api.ts @@ -141,7 +141,7 @@ export const logout = async (): Promise => { } export const getApiKey = async (): Promise<{ key: string }> => { - const response = await fetch("/api/v2/api-keys", { + const response = await fetch("/api/v2/users/me/keys", { method: "POST", })