feat: Login via CLI (#298)

Fixes #210 - this isPR implements `coder login` in the case where the default user is already created.

This change adds:
- A prompt in the case where there is not an initial user that opens the server URL + requests a session token
  - This ports over some code from v1 for the `openURL` and `isWSL` functions to support opening the browser
- A `/api/v2/api-keys` endpoint that can be `POST`'d to in order to request a new api key for a user
  - This route was inspired by the v1 functionality
- A `cli-auth` route + page that shows the generated api key
- Tests for the new code + storybook for the new UI

The `/cli-auth` route, like in v1, is very minimal:

<img width="624" alt="Screen Shot 2022-02-16 at 5 05 07 PM" src="https://user-images.githubusercontent.com/88213859/154384627-78ab9841-27bf-490f-9bbe-23f8173c9e97.png">

And the terminal UX looks like this:

![2022-02-16 17 13 29](https://user-images.githubusercontent.com/88213859/154385225-509c78d7-840c-4cab-8f1e-074fede8f97e.gif)
This commit is contained in:
Bryan 2022-02-17 20:09:33 -08:00 committed by GitHub
parent 80c5c93d8a
commit 3f7781403d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 372 additions and 4 deletions

View File

@ -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 <url>",
@ -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)
}

View File

@ -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!")
})
}

View File

@ -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) {

View File

@ -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

View File

@ -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) {

View File

@ -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) {

1
go.mod
View File

@ -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

1
go.sum
View File

@ -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=

View File

@ -139,3 +139,16 @@ export const logout = async (): Promise<void> => {
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()
}

View File

@ -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<CliAuthTokenProps> = (args) => <CliAuthToken {...args} />
export const Example = Template.bind({})
Example.args = {}

View File

@ -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(<CliAuthToken sessionToken="test-token" />)
// Then
await screen.findByText("Session Token")
await screen.findByText("test-token")
})
})

View File

@ -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<CliAuthTokenProps> = ({ sessionToken }) => {
const styles = useStyles()
return (
<Paper className={styles.container}>
<Typography className={styles.title}>Session Token</Typography>
<CodeExample code={sessionToken} />
</Paper>
)
}
const useStyles = makeStyles((theme) => ({
title: {
marginBottom: theme.spacing(2),
},
container: {
maxWidth: "680px",
padding: theme.spacing(2),
},
}))

View File

@ -1 +1,2 @@
export * from "./CliAuthToken"
export * from "./SignInForm"

44
site/pages/cli-auth.tsx Normal file
View File

@ -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<string | null>(null)
useEffect(() => {
if (me?.id) {
void getApiKey().then(({ key }) => {
setApiKey(key)
})
}
}, [me?.id])
if (!apiKey) {
return <FullScreenLoader />
}
return (
<div className={styles.root}>
<CliAuthToken sessionToken={apiKey} />
</div>
)
}
const useStyles = makeStyles(() => ({
root: {
width: "100vw",
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
},
}))
export default CliAuthenticationPage