mirror of https://github.com/coder/coder.git
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:
parent
80c5c93d8a
commit
3f7781403d
98
cli/login.go
98
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 <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)
|
||||
}
|
||||
|
|
|
@ -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!")
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
1
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
|
||||
|
|
1
go.sum
1
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=
|
||||
|
|
13
site/api.ts
13
site/api.ts
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 = {}
|
|
@ -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")
|
||||
})
|
||||
})
|
|
@ -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),
|
||||
},
|
||||
}))
|
|
@ -1 +1,2 @@
|
|||
export * from "./CliAuthToken"
|
||||
export * from "./SignInForm"
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue