Skip to content

Commit

Permalink
Merge pull request #525 from jastang/merge-login-cmds
Browse files Browse the repository at this point in the history
Merge `web-login` into `login`
  • Loading branch information
jastang committed May 14, 2024
2 parents 0fde460 + aba98b6 commit fa32c2c
Show file tree
Hide file tree
Showing 5 changed files with 290 additions and 340 deletions.
285 changes: 243 additions & 42 deletions cmd/up/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,21 @@ import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"

"github.com/alecthomas/kong"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/golang-jwt/jwt"
"github.com/mdp/qrterminal/v3"
"github.com/pkg/browser"
"github.com/pterm/pterm"

"github.com/upbound/up-sdk-go/service/userinfo"
Expand All @@ -41,10 +47,15 @@ const (
defaultTimeout = 30 * time.Second
loginPath = "/v1/login"

webLogin = "/login"
issueEndpoint = "/v1/issueTOTP"
exchangeEndpoint = "/v1/checkTOTP"
totpDisplay = "/cli/loginCode"
loginResultEndpoint = "/cli/loginResult"

errLoginFailed = "unable to login"
errReadBody = "unable to read response body"
errParseCookieFmt = "unable to parse session cookie: %s"
errNoUserOrToken = "either username or token must be provided"
errNoIDInToken = "token is missing ID"
errUpdateConfig = "unable to update config file"
)
Expand Down Expand Up @@ -80,77 +91,98 @@ func (c *LoginCmd) AfterApply(kongCtx *kong.Context) error {
if c.Token != "" {
return nil
}
if c.Username == "" {
username, err := c.prompter.Prompt("Username", false)
if err != nil {
return err
}
c.Username = username
}
if c.Password == "" {
// Only prompt for password if username flag is explicitly passed
if c.Password == "" && c.Username != "" {
password, err := c.prompter.Prompt("Password", true)
if err != nil {
return err
}
c.Password = password
return nil
}
u := *upCtx.Domain
u.Host = "accounts." + u.Host
c.accountsEndpoint = u

return nil
}

// LoginCmd adds a user or token profile with session token to the up config
// file.
// file if a username is passed, but defaults to launching a web browser to authenticate with Upbound.
type LoginCmd struct {
client uphttp.Client
stdin io.Reader
prompter input.Prompter

Username string `short:"u" env:"UP_USER" xor:"identifier" help:"Username used to execute command."`
Password string `short:"p" env:"UP_PASSWORD" help:"Password for specified user. '-' to read from stdin."`
Token string `short:"t" env:"UP_TOKEN" xor:"identifier" help:"Token used to execute command. '-' to read from stdin."`
Token string `short:"t" env:"UP_TOKEN" hidden:"" xor:"identifier" help:"Token used to execute command. '-' to read from stdin."`

accountsEndpoint url.URL
// Common Upbound API configuration
Flags upbound.Flags `embed:""`
}

// Run executes the login command.
func (c *LoginCmd) Run(ctx context.Context, p pterm.TextPrinter, upCtx *upbound.Context) error { // nolint:gocyclo
if c.Token == "-" {
b, err := io.ReadAll(c.stdin)
if err != nil {
return err
}
c.Token = strings.TrimSpace(string(b))
// simple auth using explicit flags
if c.Username != "" || c.Token != "" {
return c.simpleAuth(ctx, p, upCtx)
}
if c.Password == "-" {
b, err := io.ReadAll(c.stdin)
if err != nil {
return err
}
c.Password = strings.TrimSpace(string(b))

// start webserver listening on port
token := make(chan string, 1)
redirect := make(chan string, 1)
defer close(token)
defer close(redirect)

cb := callbackServer{
token: token,
redirect: redirect,
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
auth, profType, err := constructAuth(c.Username, c.Token, c.Password)
err := cb.startServer()
if err != nil {
return errors.Wrap(err, errLoginFailed)
}
jsonStr, err := json.Marshal(auth)
if err != nil {
return errors.Wrap(err, errLoginFailed)
defer cb.shutdownServer(ctx) //nolint:errcheck

resultEP := c.accountsEndpoint
resultEP.Path = loginResultEndpoint
browser.Stderr = nil
browser.Stdout = nil
if err := browser.OpenURL(getEndpoint(c.accountsEndpoint, *upCtx.APIEndpoint, fmt.Sprintf("http://localhost:%d", cb.port))); err != nil {
ep := getEndpoint(c.accountsEndpoint, *upCtx.APIEndpoint, "")
qrterminal.Generate(ep, qrterminal.L, os.Stdout)
fmt.Println("Could not open a browser!")
fmt.Println("Please go to", ep, "and then enter code manually")
// TODO(nullable-eth): Add a prompter with timeout? Difficult to know when they actually
// finished login to know when the TOTP would expire
t, err := c.prompter.Prompt("Code", false)
if err != nil {
return errors.Wrap(err, errLoginFailed)
}
token <- t
}
loginEndpoint := *upCtx.APIEndpoint
loginEndpoint.Path = loginPath
req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginEndpoint.String(), bytes.NewReader(jsonStr))
if err != nil {
return errors.Wrap(err, errLoginFailed)

// wait for response on webserver or timeout
timeout := uint(5)
var t string
select {
case <-time.After(time.Duration(timeout) * time.Minute):
break
case t = <-token:
break
}
req.Header.Set("Content-Type", "application/json")
res, err := c.client.Do(req)
if err != nil {

if err := c.exchangeTokenForSession(ctx, p, upCtx, t); err != nil {
resultEP.RawQuery = url.Values{
"message": []string{err.Error()},
}.Encode()
redirect <- resultEP.String()
return errors.Wrap(err, errLoginFailed)
}
defer res.Body.Close() // nolint:gosec,errcheck
return errors.Wrap(setSession(ctx, p, upCtx, res, profType, auth.ID), errLoginFailed)
redirect <- resultEP.String()
return nil
}

// auth is the request body sent to authenticate a user or token.
Expand Down Expand Up @@ -213,9 +245,6 @@ func setSession(ctx context.Context, p pterm.TextPrinter, upCtx *upbound.Context
// constructAuth constructs the body of an Upbound Cloud authentication request
// given the provided credentials.
func constructAuth(username, token, password string) (*auth, profile.TokenType, error) {
if username == "" && token == "" {
return nil, "", errors.New(errNoUserOrToken)
}
id, profType, err := parseID(username, token)
if err != nil {
return nil, "", err
Expand Down Expand Up @@ -266,3 +295,175 @@ func extractSession(res *http.Response, cookieName string) (string, error) {
func isEmail(user string) bool {
return strings.Contains(user, "@")
}

func getEndpoint(account url.URL, api url.URL, local string) string {
totp := local
if local == "" {
t := account
t.Path = totpDisplay
totp = t.String()
}
issueEP := api
issueEP.Path = issueEndpoint
issueEP.RawQuery = url.Values{
"returnTo": []string{totp},
}.Encode()

loginEP := account
loginEP.Path = webLogin
loginEP.RawQuery = url.Values{
"returnTo": []string{issueEP.String()},
}.Encode()
return loginEP.String()
}

func (c *LoginCmd) exchangeTokenForSession(ctx context.Context, p pterm.TextPrinter, upCtx *upbound.Context, t string) error {
if t == "" {
return errors.New("failed to receive callback from web login")
}

ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()

e := *upCtx.APIEndpoint
e.Path = exchangeEndpoint
e.RawQuery = url.Values{
"totp": []string{t},
}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.String(), nil)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")

res, err := c.client.Do(req)
if err != nil {
return err
}
defer res.Body.Close() // nolint:gosec,errcheck

var user map[string]interface{} = make(map[string]interface{})
if err := json.NewDecoder(res.Body).Decode(&user); err != nil {
return err
}
username, ok := user["username"].(string)
if !ok {
return errors.New("failed to get user details, code may have expired")
}
return setSession(ctx, p, upCtx, res, profile.TokenTypeUser, username)
}

type callbackServer struct {
token chan string
redirect chan string
port int
srv *http.Server
}

func (cb *callbackServer) getResponse(w http.ResponseWriter, r *http.Request) {
v := r.URL.Query()["totp"]
token := ""
if len(v) == 1 {
token = v[0]
}

// send the token
cb.token <- token

// wait for success or failure redirect
rd := <-cb.redirect

http.Redirect(w, r, rd, http.StatusSeeOther)
}

func (cb *callbackServer) shutdownServer(ctx context.Context) error {
return cb.srv.Shutdown(ctx)
}

func (cb *callbackServer) startServer() (err error) {
cb.port, err = cb.getPort()
if err != nil {
return err
}

mux := http.NewServeMux()
mux.HandleFunc("/", cb.getResponse)
cb.srv = &http.Server{
Handler: mux,
Addr: fmt.Sprintf(":%d", cb.port),
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() error {
if err := cb.srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}() //nolint:errcheck

return nil
}

func (cb *callbackServer) getPort() (int, error) {
// Create a new server without specifying a port
// which will result in an open port being chosen
server, err := net.Listen("tcp", "localhost:0")

// If there's an error it likely means no ports
// are available or something else prevented finding
// an open port
if err != nil {
return 0, err
}
defer server.Close() //nolint:errcheck

// Split the host from the port
_, portString, err := net.SplitHostPort(server.Addr().String())
if err != nil {
return 0, err
}

// Return the port as an int
return strconv.Atoi(portString)
}

func (c *LoginCmd) simpleAuth(ctx context.Context, p pterm.TextPrinter, upCtx *upbound.Context) error {
if c.Token == "-" {
b, err := io.ReadAll(c.stdin)
if err != nil {
return err
}
c.Token = strings.TrimSpace(string(b))
}
if c.Password == "-" {
b, err := io.ReadAll(c.stdin)
if err != nil {
return err
}
c.Password = strings.TrimSpace(string(b))
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
auth, profType, err := constructAuth(c.Username, c.Token, c.Password)
if err != nil {
return errors.Wrap(err, errLoginFailed)
}
jsonStr, err := json.Marshal(auth)
if err != nil {
return errors.Wrap(err, errLoginFailed)
}
loginEndpoint := *upCtx.APIEndpoint
loginEndpoint.Path = loginPath
req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginEndpoint.String(), bytes.NewReader(jsonStr))
if err != nil {
return errors.Wrap(err, errLoginFailed)
}
req.Header.Set("Content-Type", "application/json")
res, err := c.client.Do(req)
if err != nil {
return errors.Wrap(err, errLoginFailed)
}
defer res.Body.Close() // nolint:gosec,errcheck
return errors.Wrap(setSession(ctx, p, upCtx, res, profType, auth.ID), errLoginFailed)
}

0 comments on commit fa32c2c

Please sign in to comment.