From fcc393e2f01604730c3680f02561639726e09594 Mon Sep 17 00:00:00 2001 From: Michael Borkenstein Date: Sun, 8 Nov 2020 21:25:35 -0600 Subject: [PATCH] AUTH-3221: Saves org token to disk and uses it to refresh the app token --- cmd/cloudflared/access/cmd.go | 6 +- cmd/cloudflared/path/path.go | 29 +++- cmd/cloudflared/token/token.go | 232 +++++++++++++++++++++++---- cmd/cloudflared/transfer/transfer.go | 11 +- cmd/cloudflared/tunnel/login.go | 7 +- sshgen/sshgen.go | 2 +- sshgen/sshgen_test.go | 2 +- 7 files changed, 237 insertions(+), 52 deletions(-) diff --git a/cmd/cloudflared/access/cmd.go b/cmd/cloudflared/access/cmd.go index 20f70816..9ef3abb9 100644 --- a/cmd/cloudflared/access/cmd.go +++ b/cmd/cloudflared/access/cmd.go @@ -222,7 +222,7 @@ func login(c *cli.Context) error { return err } - cfdToken, err := token.GetTokenIfExists(appURL) + cfdToken, err := token.GetAppTokenIfExists(appURL) if err != nil { fmt.Fprintln(os.Stderr, "Unable to find token for provided application.") return err @@ -267,7 +267,7 @@ func curl(c *cli.Context) error { return err } - tok, err := token.GetTokenIfExists(appURL) + tok, err := token.GetAppTokenIfExists(appURL) if err != nil || tok == "" { if allowRequest { logger.Info("You don't have an Access token set. Please run access token to fetch one.") @@ -295,7 +295,7 @@ func generateToken(c *cli.Context) error { fmt.Fprintln(os.Stderr, "Please provide a url.") return err } - tok, err := token.GetTokenIfExists(appURL) + tok, err := token.GetAppTokenIfExists(appURL) if err != nil || tok == "" { fmt.Fprintln(os.Stderr, "Unable to find token for provided application. Please run token command to generate token.") return err diff --git a/cmd/cloudflared/path/path.go b/cmd/cloudflared/path/path.go index 1179250e..b3ad1497 100644 --- a/cmd/cloudflared/path/path.go +++ b/cmd/cloudflared/path/path.go @@ -11,8 +11,27 @@ import ( "github.com/mitchellh/go-homedir" ) -// GenerateFilePathFromURL will return a filepath for given access application url -func GenerateFilePathFromURL(url *url.URL, suffix string) (string, error) { +// GenerateAppTokenFilePathFromURL will return a filepath for given Access org token +func GenerateAppTokenFilePathFromURL(url *url.URL, suffix string) (string, error) { + configPath, err := getConfigPath() + if err != nil { + return "", err + } + name := strings.Replace(fmt.Sprintf("%s%s-%s", url.Hostname(), url.EscapedPath(), suffix), "/", "-", -1) + return filepath.Join(configPath, name), nil +} + +// GenerateOrgTokenFilePathFromURL will return a filepath for given Access application token +func GenerateOrgTokenFilePathFromURL(authDomain string) (string, error) { + configPath, err := getConfigPath() + if err != nil { + return "", err + } + name := strings.Replace(fmt.Sprintf("%s-org-token", authDomain), "/", "-", -1) + return filepath.Join(configPath, name), nil +} + +func getConfigPath() (string, error) { configPath, err := homedir.Expand(config.DefaultConfigSearchDirectories()[0]) if err != nil { return "", err @@ -22,9 +41,5 @@ func GenerateFilePathFromURL(url *url.URL, suffix string) (string, error) { // create config directory if doesn't already exist err = os.Mkdir(configPath, 0700) } - if err != nil { - return "", err - } - name := strings.Replace(fmt.Sprintf("%s%s-%s", url.Hostname(), url.EscapedPath(), suffix), "/", "-", -1) - return filepath.Join(configPath, name), nil + return configPath, err } diff --git a/cmd/cloudflared/token/token.go b/cmd/cloudflared/token/token.go index 05dfba8e..f59b5a5e 100644 --- a/cmd/cloudflared/token/token.go +++ b/cmd/cloudflared/token/token.go @@ -5,9 +5,11 @@ import ( "encoding/json" "fmt" "io/ioutil" + "net/http" "net/url" "os" "os/signal" + "strings" "syscall" "time" @@ -17,10 +19,12 @@ import ( "github.com/cloudflare/cloudflared/logger" "github.com/cloudflare/cloudflared/origin" "github.com/coreos/go-oidc/jose" + "github.com/pkg/errors" ) const ( - keyName = "token" + keyName = "token" + tokenHeader = "CF_Authorization" ) type lock struct { @@ -34,7 +38,7 @@ type signalHandler struct { signals []os.Signal } -type jwtPayload struct { +type appJWTPayload struct { Aud []string `json:"aud"` Email string `json:"email"` Exp int `json:"exp"` @@ -45,7 +49,17 @@ type jwtPayload struct { Subt string `json:"sub"` } -func (p jwtPayload) isExpired() bool { +type orgJWTPayload struct { + appJWTPayload + Aud string `json:"aud"` +} + +type transferServiceResponse struct { + AppToken string `json:"app_token"` + OrgToken string `json:"org_token"` +} + +func (p appJWTPayload) isExpired() bool { return int(time.Now().Unix()) > p.Exp } @@ -141,55 +155,173 @@ func FetchToken(appURL *url.URL, logger logger.Service) (string, error) { // getToken will either load a stored token or generate a new one func getToken(appURL *url.URL, useHostOnly bool, logger logger.Service) (string, error) { - if token, err := GetTokenIfExists(appURL); token != "" && err == nil { + if token, err := GetAppTokenIfExists(appURL); token != "" && err == nil { return token, nil } - path, err := path.GenerateFilePathFromURL(appURL, keyName) + appTokenPath, err := path.GenerateAppTokenFilePathFromURL(appURL, keyName) if err != nil { - return "", err + return "", errors.Wrap(err, "failed to generate app token file path") } - fileLock := newLock(path) - - err = fileLock.Acquire() - if err != nil { - return "", err + fileLockAppToken := newLock(appTokenPath) + if err = fileLockAppToken.Acquire(); err != nil { + return "", errors.Wrap(err, "failed to acquire app token lock") } - defer fileLock.Release() + defer fileLockAppToken.Release() // check to see if another process has gotten a token while we waited for the lock - if token, err := GetTokenIfExists(appURL); token != "" && err == nil { + if token, err := GetAppTokenIfExists(appURL); token != "" && err == nil { return token, nil } + // If an app token couldnt be found on disk, check for an org token and attempt to exchange it for an app token. + var orgTokenPath string + // Get auth domain to format into org token file path + if authDomain, err := getAuthDomain(appURL); err != nil { + logger.Errorf("failed to get auth domain: %s", err) + } else { + orgToken, err := GetOrgTokenIfExists(authDomain) + if err != nil { + orgTokenPath, err = path.GenerateOrgTokenFilePathFromURL(authDomain) + if err != nil { + return "", errors.Wrap(err, "failed to generate org token file path") + } + + fileLockOrgToken := newLock(orgTokenPath) + if err = fileLockOrgToken.Acquire(); err != nil { + return "", errors.Wrap(err, "failed to acquire org token lock") + } + defer fileLockOrgToken.Release() + // check if an org token has been created since the lock was acquired + orgToken, err = GetOrgTokenIfExists(authDomain) + } + if err == nil { + if appToken, err := exchangeOrgToken(appURL, orgToken); err != nil { + logger.Debugf("failed to exchange org token for app token: %s", err) + } else { + if err := ioutil.WriteFile(appTokenPath, []byte(appToken), 0600); err != nil { + return "", errors.Wrap(err, "failed to write app token to disk") + } + return appToken, nil + } + } + } + return getTokensFromEdge(appURL, appTokenPath, orgTokenPath, useHostOnly, logger) + +} + +// getTokensFromEdge will attempt to use the transfer service to retrieve an app and org token, save them to disk, +// and return the app token. +func getTokensFromEdge(appURL *url.URL, appTokenPath, orgTokenPath string, useHostOnly bool, logger logger.Service) (string, error) { + // If no org token exists or if it couldnt be exchanged for an app token, then run the transfer service flow. + // this weird parameter is the resource name (token) and the key/value // we want to send to the transfer service. the key is token and the value // is blank (basically just the id generated in the transfer service) - token, err := transfer.Run(appURL, keyName, keyName, "", path, true, useHostOnly, logger) + resourceData, err := transfer.Run(appURL, keyName, keyName, "", true, useHostOnly, logger) if err != nil { - return "", err + return "", errors.Wrap(err, "failed to run transfer service") + } + var resp transferServiceResponse + if err = json.Unmarshal(resourceData, &resp); err != nil { + return "", errors.Wrap(err, "failed to marshal transfer service response") } - return string(token), nil + // If we were able to get the auth domain and generate an org token path, lets write it to disk. + if orgTokenPath != "" { + if err := ioutil.WriteFile(orgTokenPath, []byte(resp.OrgToken), 0600); err != nil { + return "", errors.Wrap(err, "failed to write org token to disk") + } + } + + if err := ioutil.WriteFile(appTokenPath, []byte(resp.AppToken), 0600); err != nil { + return "", errors.Wrap(err, "failed to write app token to disk") + } + + return resp.AppToken, nil + } -// GetTokenIfExists will return the token from local storage if it exists and not expired -func GetTokenIfExists(url *url.URL) (string, error) { - path, err := path.GenerateFilePathFromURL(url, keyName) - if err != nil { - return "", err - } - content, err := ioutil.ReadFile(path) - if err != nil { - return "", err - } - token, err := jose.ParseJWT(string(content)) - if err != nil { - return "", err +// getAuthDomain makes a request to the appURL and stops at the first redirect. The 302 location header will contain the +// auth domain +func getAuthDomain(appURL *url.URL) (string, error) { + client := &http.Client{ + // do not follow redirects + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: time.Second * 7, } - var payload jwtPayload + authDomainReq, err := http.NewRequest("HEAD", appURL.String(), nil) + if err != nil { + return "", errors.Wrap(err, "failed to create auth domain request") + } + resp, err := client.Do(authDomainReq) + if err != nil { + return "", errors.Wrap(err, "failed to get auth domain") + } + resp.Body.Close() + location, err := resp.Location() + if err != nil { + return "", fmt.Errorf("failed to get auth domain. Received status code %d from %s", resp.StatusCode, appURL.String()) + } + return location.Hostname(), nil + +} + +// exchangeOrgToken attaches an org token to a request to the appURL and returns an app token. This uses the Access SSO +// flow to automatically generate and return an app token without the login page. +func exchangeOrgToken(appURL *url.URL, orgToken string) (string, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // attach org token to login request + if strings.Contains(req.URL.Path, "cdn-cgi/access/login") { + req.AddCookie(&http.Cookie{Name: tokenHeader, Value: orgToken}) + } + // stop after hitting authorized endpoint since it will contain the app token + if strings.Contains(via[len(via)-1].URL.Path, "cdn-cgi/access/authorized") { + return http.ErrUseLastResponse + } + return nil + }, + Timeout: time.Second * 7, + } + + appTokenRequest, err := http.NewRequest("HEAD", appURL.String(), nil) + if err != nil { + return "", errors.Wrap(err, "failed to create app token request") + } + resp, err := client.Do(appTokenRequest) + if err != nil { + return "", errors.Wrap(err, "failed to get app token") + } + resp.Body.Close() + var appToken string + for _, c := range resp.Cookies() { + if c.Name == tokenHeader { + appToken = c.Value + break + } + } + + if len(appToken) > 0 { + return appToken, nil + } + return "", fmt.Errorf("response from %s did not contain app token", resp.Request.URL.String()) +} + +func GetOrgTokenIfExists(authDomain string) (string, error) { + path, err := path.GenerateOrgTokenFilePathFromURL(authDomain) + if err != nil { + return "", err + } + token, err := getTokenIfExists(path) + if err != nil { + return "", err + } + var payload orgJWTPayload err = json.Unmarshal(token.Payload, &payload) if err != nil { return "", err @@ -199,13 +331,49 @@ func GetTokenIfExists(url *url.URL) (string, error) { err := os.Remove(path) return "", err } - return token.Encode(), nil } +func GetAppTokenIfExists(url *url.URL) (string, error) { + path, err := path.GenerateAppTokenFilePathFromURL(url, keyName) + if err != nil { + return "", err + } + token, err := getTokenIfExists(path) + if err != nil { + return "", err + } + var payload appJWTPayload + err = json.Unmarshal(token.Payload, &payload) + if err != nil { + return "", err + } + + if payload.isExpired() { + err := os.Remove(path) + return "", err + } + return token.Encode(), nil + +} + +// GetTokenIfExists will return the token from local storage if it exists and not expired +func getTokenIfExists(path string) (*jose.JWT, error) { + content, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + token, err := jose.ParseJWT(string(content)) + if err != nil { + return nil, err + } + + return &token, nil +} + // RemoveTokenIfExists removes the a token from local storage if it exists func RemoveTokenIfExists(url *url.URL) error { - path, err := path.GenerateFilePathFromURL(url, keyName) + path, err := path.GenerateAppTokenFilePathFromURL(url, keyName) if err != nil { return err } diff --git a/cmd/cloudflared/transfer/transfer.go b/cmd/cloudflared/transfer/transfer.go index 73e29b04..f4e08016 100644 --- a/cmd/cloudflared/transfer/transfer.go +++ b/cmd/cloudflared/transfer/transfer.go @@ -3,10 +3,8 @@ package transfer import ( "bytes" "encoding/base64" - "errors" "fmt" "io" - "io/ioutil" "net/http" "net/url" "os" @@ -15,6 +13,7 @@ import ( "github.com/cloudflare/cloudflared/cmd/cloudflared/encrypter" "github.com/cloudflare/cloudflared/cmd/cloudflared/shell" "github.com/cloudflare/cloudflared/logger" + "github.com/pkg/errors" ) const ( @@ -28,7 +27,7 @@ const ( // The "dance" we refer to is building a HTTP request, opening that in a browser waiting for // the user to complete an action, while it long polls in the background waiting for an // action to be completed to download the resource. -func Run(transferURL *url.URL, resourceName, key, value, path string, shouldEncrypt bool, useHostOnly bool, logger logger.Service) ([]byte, error) { +func Run(transferURL *url.URL, resourceName, key, value string, shouldEncrypt bool, useHostOnly bool, logger logger.Service) ([]byte, error) { encrypterClient, err := encrypter.New("cloudflared_priv.pem", "cloudflared_pub.pem") if err != nil { return nil, err @@ -72,11 +71,8 @@ func Run(transferURL *url.URL, resourceName, key, value, path string, shouldEncr resourceData = buf } - if err := ioutil.WriteFile(path, resourceData, 0600); err != nil { - return nil, err - } - return resourceData, nil + } // BuildRequestURL creates a request suitable for a resource transfer. @@ -93,6 +89,7 @@ func buildRequestURL(baseURL *url.URL, key, value string, cli, useHostOnly bool) return baseURL.String(), nil } q.Set("redirect_url", baseURL.String()) // we add the token as a query param on both the redirect_url and the main url + q.Set("send_org_token", "true") // indicates that the cli endpoint should return both the org and app token baseURL.RawQuery = q.Encode() // and this actual baseURL. baseURL.Path = "cdn-cgi/access/cli" return baseURL.String(), nil diff --git a/cmd/cloudflared/tunnel/login.go b/cmd/cloudflared/tunnel/login.go index 1f2aee2a..99c6d15d 100644 --- a/cmd/cloudflared/tunnel/login.go +++ b/cmd/cloudflared/tunnel/login.go @@ -2,6 +2,7 @@ package tunnel import ( "fmt" + "io/ioutil" "net/url" "os" "path/filepath" @@ -58,12 +59,16 @@ func login(c *cli.Context) error { return err } - _, err = transfer.Run(loginURL, "cert", "callback", callbackStoreURL, path, false, false, logger) + resourceData, err := transfer.Run(loginURL, "cert", "callback", callbackStoreURL, false, false, logger) if err != nil { fmt.Fprintf(os.Stderr, "Failed to write the certificate due to the following error:\n%v\n\nYour browser will download the certificate instead. You will have to manually\ncopy it to the following path:\n\n%s\n", err, path) return err } + if err := ioutil.WriteFile(path, resourceData, 0600); err != nil { + return errors.Wrap(err, fmt.Sprintf("error writing cert to %s", path)) + } + fmt.Fprintf(os.Stdout, "You have successfully logged in.\nIf you wish to copy your credentials to a server, they have been saved to:\n%s\n", path) return nil } diff --git a/sshgen/sshgen.go b/sshgen/sshgen.go index 2d6f326b..22b6ec60 100644 --- a/sshgen/sshgen.go +++ b/sshgen/sshgen.go @@ -52,7 +52,7 @@ var mockRequest func(url, contentType string, body io.Reader) (*http.Response, e // GenerateShortLivedCertificate generates and stores a keypair for short lived certs func GenerateShortLivedCertificate(appURL *url.URL, token string) error { - fullName, err := cfpath.GenerateFilePathFromURL(appURL, keyName) + fullName, err := cfpath.GenerateAppTokenFilePathFromURL(appURL, keyName) if err != nil { return err } diff --git a/sshgen/sshgen_test.go b/sshgen/sshgen_test.go index d7094d14..8d1ada0f 100644 --- a/sshgen/sshgen_test.go +++ b/sshgen/sshgen_test.go @@ -35,7 +35,7 @@ func TestCertGenSuccess(t *testing.T) { url, _ := url.Parse("https://cf-test-access.com/testpath") token := tokenGenerator() - fullName, err := cfpath.GenerateFilePathFromURL(url, keyName) + fullName, err := cfpath.GenerateAppTokenFilePathFromURL(url, keyName) assert.NoError(t, err) pubKeyName := fullName + ".pub"