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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy