blob: 6505a201b36f31749be86705d15bb6a6d0052a12 [file] [log] [blame]
// Copyright 2018 The Fuchsia Authors. All rights reserved.
// // Use of this source code is governed by a BSD-style
// // license that can be found in the LICENSE file.
package gerrit
import (
"bufio"
"bytes"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"go.fuchsia.dev/jiri"
"go.fuchsia.dev/jiri/gitutil"
"golang.org/x/net/publicsuffix"
)
const (
cookieHTTPOnlyPrefix = "#HttpOnly_"
generatePasswordURL = "https://%s/new-password"
ssoCookieAge = 30 * 24 * time.Hour // cookie expiration time from HTTP response
ssoCookieLife = 20 * time.Hour // actual cookie expiration time according to documentation
)
var (
ErrRedirectOnGerrit = errors.New("got redirection while downloading file from gerrit server")
ErrRedirectOnGerritSSO = errors.New("got redirection while downloading file from gerrit server using SSO cookie")
ErrCookieNotExist = errors.New("cookie file not found")
ErrSSOPathNotSet = errors.New("master SSO cookie path is not set, please run \"jiri init --sso-cookie-path PATH_TO_SSO\" and try again")
ErrSSOCookieExpireInvalid = errors.New("SSO cookie is either invalid or expired, please run \"glogin\" and try again")
ErrHTTPForbidden = errors.New("server return HTTP 403, cookies are not accepted")
ssoCookiePath = "" // path to user master SSO cookie
ssoCookieCachePath = "" // path to jiri managed SSO cookie cache
bootstrapOnce sync.Once
)
// The golang cookiejar implementation will remove the domain name, expiration
// time etc. from the cookies returned from Cookies(*url.URL) method (maybe for
// security reasons). The expiration time is crucial for caching the sso
// cookies, therefore we build our own cookiejar to save a copy of unstripped
// SSO cookie.
type ssoCookieJar struct {
jar http.CookieJar
ssoCookies map[string]*http.Cookie
}
// BootstrapGerritSSO will setup cookie cache for SSO cookies and setup the
// path for master SSO cookie. Due to security concerns, we cannot hard code sso
// cookie paths in jiri, instead, we ask user to supply path to master sso
// cookie path using "jiri init --sso-cookie-path" command.
func BootstrapGerritSSO(jirix *jiri.X) {
bootstrapOnce.Do(func() {
ssoCookiePath = jirix.SsoCookiePath
ssoCookieCachePath = path.Join(jirix.RootMetaDir(), "jiricookies.txt")
})
}
// newSSOCookieJar constructs a new ssoCookieJar instance. Based on golang
// cookiejar implementation, the error will always be nil.
func newSSOCookieJar() (*ssoCookieJar, error) {
j, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
if err != nil {
return nil, err
}
return &ssoCookieJar{
jar: j,
ssoCookies: make(map[string]*http.Cookie),
}, nil
}
// SetCookies overrides cookiejar.SetCookies method, which implements the
// SetCookies method of the http.CookieJar interface. It does nothing if
// the URL's scheme is not HTTP or HTTPS.
func (j *ssoCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
// Save/update SSO cookies
for _, cookie := range cookies {
if cookie.Name == "SSO" {
if j.ssoCookies[u.Host] == nil || j.ssoCookies[u.Host].Expires.Before(cookie.Expires) {
j.ssoCookies[u.Host] = cookie
}
}
}
j.jar.SetCookies(u, cookies)
}
// Cookies overrides cookiejar.Cookies method, which implements the Cookies
// method of the http.CookieJar interface. It returns an empty slice if the
// URL's scheme is not HTTP or HTTPS.
func (j *ssoCookieJar) Cookies(u *url.URL) (cookies []*http.Cookie) {
return j.jar.Cookies(u)
}
// GetSSOCookie will return saved SSO cookie for url u. It will return nil
// if that cookie does not exist.
func (j *ssoCookieJar) GetSSOCookie(u *url.URL) (cookie *http.Cookie) {
return j.ssoCookies[u.Host]
}
// FetchFile downloads a file and returns its content to a byte slice. It will
// return ErrRedirectOnGerrit if redirection is detected, which indicates that
// user authentication is required.
func FetchFile(gerritHost, path string) ([]byte, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
downloadPath := gerritHost + path
resp, err := client.Get(downloadPath)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Check if there is an redirection
if resp.StatusCode != http.StatusOK {
if _, err := resp.Location(); err == nil {
return nil, ErrRedirectOnGerrit
}
return nil, fmt.Errorf("expecting status code %d from %q, got %d ", http.StatusOK, downloadPath, resp.StatusCode)
}
return ioutil.ReadAll(resp.Body)
}
func fetchFileWithJar(jirix *jiri.X, gerritHost, path string, jar http.CookieJar) ([]byte, error) {
hostName := gerritHost[len("https://"):]
client := &http.Client{
Jar: jar,
}
downloadPath := gerritHost + path
resp, err := client.Get(downloadPath)
if err != nil {
return nil, err
}
if resp.Request != nil {
if resp.Request.URL.Host != hostName {
// If final request have different hostname than gerritHost
// It indicates gerrit SSO cookie is either invalid or expired
return nil, ErrRedirectOnGerritSSO
}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Unexpected response from server, log HTTP headers.
if jirix != nil {
jirix.Logger.Debugf("got HTTP %d when accessing %q", resp.StatusCode, downloadPath)
jirix.Logger.Debugf(dumpHeaders(resp))
}
if resp.StatusCode == http.StatusForbidden {
return nil, ErrHTTPForbidden
}
return nil, fmt.Errorf("expecting status code %d from %q, got %d ", http.StatusOK, downloadPath, resp.StatusCode)
}
return ioutil.ReadAll(resp.Body)
}
func dumpHeaders(resp *http.Response) string {
var output bytes.Buffer
header := resp.Request.Header
if header != nil {
output.WriteString("HTTP Request Header:\n")
for k, v := range header {
output.WriteString(fmt.Sprintf("\t%s: %v\n", k, v))
}
}
header = resp.Header
if header != nil {
output.WriteString("\nHTTP Response Header:\n")
for k, v := range header {
output.WriteString(fmt.Sprintf("\t%s: %v\n", k, v))
}
}
output.WriteByte('\n')
return output.String()
}
// FetchFileSSO downloads a file from a gerrit host that requires SSO login
// and returns its content to a byte slice. Since it uses user's master SSO
// cookie, the scheme of the url should always be HTTPS, otherwise an error
// will be returned.
func FetchFileSSO(jirix *jiri.X, gerritHost, path string) ([]byte, error) {
BootstrapGerritSSO(jirix)
if !strings.HasPrefix(gerritHost, "https://") {
return nil, fmt.Errorf("Unsupported scheme for host %q", gerritHost)
}
hostName := gerritHost[len("https://"):]
fetcher := func(jirix *jiri.X, cookieCache, gerritHost string, cookieType CookieType) ([]byte, *ssoCookieJar, error) {
jar, err := LoadCookies(jirix, cookieCache, hostName, cookieType)
if err != nil {
return nil, nil, err
}
data, err := fetchFileWithJar(jirix, gerritHost, path, jar)
return data, jar, err
}
cookieType := GitCookieOnly
var data []byte
var jar *ssoCookieJar
var err error
for data, jar, err = fetcher(jirix, ssoCookieCachePath, gerritHost, cookieType); err != nil; {
switch cookieType {
case SiteSSO, MasterSSO:
jirix.Logger.Debugf("failed to fetch %s%s using sso cookies", gerritHost, path)
case GitCookieOnly:
jirix.Logger.Debugf("failed to fetch %s%s using git cookies", gerritHost, path)
}
switch err {
case ErrHTTPForbidden:
if cookieType == GitCookieOnly {
return nil, fmt.Errorf("git cookies for %s are invalid, please follow the instructions at %q to refresh your git cookies", gerritHost, fmt.Sprintf(generatePasswordURL, hostName))
}
// Unexpected access deny when using cookies other than git cookies. Report the error.
return nil, fmt.Errorf("access to %s%s is denied. Got HTTP 403 error from the server", gerritHost, path)
case ErrRedirectOnGerritSSO:
if cookieType == GitCookieOnly {
cookieType = SiteSSO
continue
}
if cookieType == SiteSSO {
cookieType = MasterSSO
continue
}
// cookieType == MasterSSO
// It generally means both gerrit SSO cookie and
// master SSO cookies are both exipred, ask user to refresh
// cookies
return nil, ErrSSOCookieExpireInvalid
default:
return nil, err
}
}
// Succesfully fetched the target file
switch cookieType {
case SiteSSO, MasterSSO:
jirix.Logger.Debugf("fetched %s:%s using sso cookies", gerritHost, path)
// Cache the SSO cookies if the file is fetched using SSO cookies
if err := CacheCookies(ssoCookieCachePath, hostName, jar); err != nil {
return nil, err
}
case GitCookieOnly:
jirix.Logger.Debugf("fetched %s:%s using git cookies", gerritHost, path)
}
return data, nil
}
// UnmarshalNSCookieData parses the Netscape-format cookies from
// data and return a slice of golang cookies.
func UnmarshalNSCookieData(data []byte) ([]*http.Cookie, error) {
var cookieBuf bytes.Buffer
if _, err := cookieBuf.Write(data); err != nil {
return nil, err
}
cookieScanner := bufio.NewScanner(&cookieBuf)
returnList := make([]*http.Cookie, 0)
for cookieScanner.Scan() {
currLine := strings.TrimSpace(cookieScanner.Text())
fields := strings.Fields(currLine)
// Skip unrelated lines
if len(fields) != 7 {
continue
}
cookie := &http.Cookie{}
if strings.HasPrefix(fields[0], cookieHTTPOnlyPrefix) {
cookie.Domain = fields[0][len(cookieHTTPOnlyPrefix):]
cookie.HttpOnly = true
} else {
cookie.Domain = fields[0]
}
cookie.Path = fields[2]
secure, err := strconv.ParseBool(fields[3])
if err != nil {
return nil, err
}
cookie.Secure = secure
timestamp, err := strconv.ParseInt(fields[4], 10, 64)
if err != nil {
return nil, err
}
cookie.Expires = time.Unix(timestamp, 0)
cookie.Name = fields[5]
cookie.Value = fields[6]
returnList = append(returnList, cookie)
}
return returnList, nil
}
// MarshalNSCookieData packs the slice of golang cookies into
// the Netscape-format cookies.
func MarshalNSCookieData(cookies []*http.Cookie) ([]byte, error) {
var cookieBuf bytes.Buffer
if cookies == nil || len(cookies) == 0 {
return cookieBuf.Bytes(), nil
}
for _, cookie := range cookies {
var builder strings.Builder
if cookie.HttpOnly {
builder.WriteString(cookieHTTPOnlyPrefix + cookie.Domain)
} else {
builder.WriteString(cookie.Domain)
}
builder.WriteRune('\t')
builder.WriteString("FALSE")
builder.WriteRune('\t')
if cookie.Path != "" {
builder.WriteString(cookie.Path)
} else {
builder.WriteString("/")
}
builder.WriteRune('\t')
if cookie.Secure {
builder.WriteString("TRUE")
} else {
builder.WriteString("FALSE")
}
builder.WriteRune('\t')
builder.WriteString(strconv.FormatInt(cookie.Expires.Unix(), 10))
builder.WriteRune('\t')
builder.WriteString(cookie.Name)
builder.WriteRune('\t')
builder.WriteString(cookie.Value)
builder.WriteRune('\n')
if _, err := cookieBuf.WriteString(builder.String()); err != nil {
return nil, err
}
}
return cookieBuf.Bytes(), nil
}
func loadJiriCookies(jiriCookiePath string) []*http.Cookie {
jiriCookieData, err := ioutil.ReadFile(jiriCookiePath)
if err != nil {
return nil
}
cookies, err := UnmarshalNSCookieData(jiriCookieData)
if err != nil {
return nil
}
return cookies
}
// CookieType specifies the type of cookies should be loaded by LoadCookies
// function.
type CookieType int
const (
// GitCookieOnly specifies that only git cookies should be loaded by
// LoadCookies function.
GitCookieOnly CookieType = iota
// SiteSSO specifies that site SSO cookie should be loaded from jiri
// cookie cache by LoadCookies function. If it is not found or experied,
// The combination of master SSO and git cookies should be loaded.
SiteSSO
// MasterSSO specifies that the combination of master SSO and git cookies
// should be loaded by LoadCookies function regardless of the status of
// site SSO cookie.
MasterSSO
)
// LoadCookies loads necessary cookies from various sources (master sso,
// gitcookies and cached jiricookies), returning a cookiejar that contains
// necessary cookies to login to the hostName. An error will be returned
// if no suitable cookie is found or if there is an I/O error.
func LoadCookies(jirix *jiri.X, jiriCookiePath, hostName string, cookieType CookieType) (*ssoCookieJar, error) {
cookieJar, err := newSSOCookieJar()
// Read jiriCookiePath, it may have cached cookies for gerrit host
if cookieType == SiteSSO {
cachedSSOCookies := loadJiriCookies(jiriCookiePath)
var cachedSSOCookie *http.Cookie
if cachedSSOCookies != nil {
for _, cookie := range cachedSSOCookies {
if cookie.Name == "SSO" && cookie.Domain == hostName {
if cookie.Expires.After(time.Now()) {
cachedSSOCookie = cookie
break
}
}
}
}
if cachedSSOCookie != nil {
cookieJar.SetCookies(&url.URL{
Scheme: "https",
Host: cachedSSOCookie.Domain,
Path: "/",
}, []*http.Cookie{cachedSSOCookie})
return cookieJar, nil
}
}
// Load master SSO if site SSO cookie is not found and cookieType
// is not GitCookieOnly.
if cookieType != GitCookieOnly {
ssoPath, err := getSSOCookiePath()
if err != nil {
if err == ErrCookieNotExist {
return nil, ErrSSOCookieExpireInvalid
}
return nil, err
}
ssoCookie, err := readMasterSSOCookie(ssoPath)
if err != nil {
return nil, err
}
if loginRequired(ssoCookie) {
return nil, ErrSSOCookieExpireInvalid
}
cookieJar.SetCookies(&url.URL{
Scheme: "https",
Host: ssoCookie.Domain,
Path: "/",
}, []*http.Cookie{ssoCookie})
}
// Read .gitcookies
gitCookiePath, err := getCookiePath(jirix)
if err != nil {
if err == ErrCookieNotExist {
return nil, fmt.Errorf("git cookies not found, please follow the instructions at %q", fmt.Sprintf(generatePasswordURL, hostName))
}
return nil, err
}
gitCookieData, err := ioutil.ReadFile(gitCookiePath)
if err != nil {
return nil, err
}
cookies, err := UnmarshalNSCookieData(gitCookieData)
// Looking for git cookies for gerrit Host
var gerritGitCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Domain == hostName && cookie.Name == "o" && strings.HasPrefix(cookie.Value, "git") {
gerritGitCookie = cookie
// Always load last git cookies as the last one is the latest one in git cookie file.
}
}
if gerritGitCookie == nil {
return nil, fmt.Errorf("cookie for %q is not found in git cookies, please follow the instructions at %q", hostName, fmt.Sprintf(generatePasswordURL, hostName))
}
cookieJar.SetCookies(&url.URL{
Scheme: "https",
Host: gerritGitCookie.Domain,
Path: "/",
}, []*http.Cookie{gerritGitCookie})
return cookieJar, nil
}
// CacheCookies saves the gerrit SSO cookie back jiriCookiePath file.
// As there is a limit on how many SSO cookies can be requested per hour,
// caching the gerrit SSO cookie allows jiri to avoid hitting the limiter.
func CacheCookies(jiriCookiePath, hostName string, cookiejar *ssoCookieJar) error {
// Read the cache first
var cookies []*http.Cookie
cookies = loadJiriCookies(jiriCookiePath)
if cookies == nil {
cookies = make([]*http.Cookie, 0)
}
// Retrieve latest gerrit SSO cookie from jar
latestSSOCookie := cookiejar.GetSSOCookie(&url.URL{
Scheme: "https",
Path: "/",
Host: hostName,
})
if latestSSOCookie == nil {
return errors.New("gerrit SSO cookie not found in cookie jar")
}
latestSSOCookie.Domain = hostName
latestSSOCookie.Path = "/"
var gerritSSOCookieExists bool
for i, cookie := range cookies {
if cookie.Name == latestSSOCookie.Name && cookie.Domain == latestSSOCookie.Domain {
gerritSSOCookieExists = true
// Only replace the cookie if the cached cookie expires earlier than latestSSOCookie in
if cookie.Expires.Before(latestSSOCookie.Expires) {
cookies[i] = latestSSOCookie
}
break
}
}
if !gerritSSOCookieExists {
cookies = append(cookies, latestSSOCookie)
}
jiriCookieData, err := MarshalNSCookieData(cookies)
if err != nil {
return err
}
tempFile, err := ioutil.TempFile(path.Dir(jiriCookiePath), ".jiricookie*")
if err != nil {
return err
}
_, err = tempFile.Write(jiriCookieData)
if err != nil {
tempFile.Close()
return err
}
tempFile.Close()
if err := os.Rename(tempFile.Name(), jiriCookiePath); err != nil {
os.Remove(tempFile.Name())
return err
}
return nil
}
func loginRequired(cookie *http.Cookie) bool {
// The correct way to determine cookie expiration time
// is to use protobuf to unmarshal ticket data in cookie
// However, we don't want to expose ticket data structure here,
// So we use a work around
cookie.Expires = cookie.Expires.Add(-ssoCookieAge).Add(ssoCookieLife)
if cookie.Expires.Before(time.Now()) {
return true
}
return false
}
func getCookiePath(jirix *jiri.X) (string, error) {
if cookieFilePath, err := gitutil.New(jirix).ConfigGetKey("http.cookiefile"); err == nil {
cookieFilePath = strings.TrimSpace(cookieFilePath)
if _, err := os.Stat(cookieFilePath); err != nil {
if os.IsNotExist(err) {
return "", ErrCookieNotExist
}
return "", err
}
return cookieFilePath, nil
}
// http.cookiefile does not exist. Raise error to ask user to refresh git cookies
return "", ErrCookieNotExist
}
func getSSOCookiePath() (string, error) {
if ssoCookiePath == "" {
return "", ErrSSOPathNotSet
}
if _, err := os.Stat(ssoCookiePath); err != nil {
if os.IsNotExist(err) {
return "", ErrCookieNotExist
}
return "", err
}
return ssoCookiePath, nil
}
func readMasterSSOCookie(path string) (*http.Cookie, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.SplitN(string(data), "\n", 2)
cookie := http.Cookie{}
for _, field := range strings.Split(lines[0], ",") {
kv := strings.SplitN(field, "=", 2)
switch kv[0] {
case "domain":
cookie.Domain = kv[1]
case "name":
cookie.Name = kv[1]
case "path":
cookie.Path = kv[1]
case "value":
cookie.Value = kv[1]
case "expires":
expires, err := strconv.ParseInt(kv[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expires time in cookie: %v", err)
}
cookie.Expires = time.Unix(int64(expires), 0)
cookie.RawExpires = kv[1]
case "secure":
if kv[1] != "True" {
return nil, fmt.Errorf("secure value in cookie is not True")
}
cookie.Secure = true
}
}
return &cookie, nil
}