From 9b2d7955419ada31bca17ef2315a7acc77192fa2 Mon Sep 17 00:00:00 2001 From: Kishan Sagathiya Date: Wed, 14 Mar 2018 19:53:45 +0530 Subject: [PATCH] Issue #179 Split handleJenkinsUIRequest() into smaller methods - Splitted handleJenkinsUIRequest() to smaller methods - Added error package, removed naked returns, decreased returned parameters - Added tests --- internal/proxy/mock_idler.go | 27 ++ internal/proxy/mock_login.go | 47 +++ internal/proxy/proxy.go | 559 ++++++++++++++++++--------------- internal/proxy/proxy_test.go | 267 ++++++++++++++++ internal/proxy/token.go | 43 +-- internal/util/errors/errors.go | 60 ++++ 6 files changed, 708 insertions(+), 295 deletions(-) create mode 100644 internal/proxy/mock_idler.go create mode 100644 internal/proxy/mock_login.go create mode 100644 internal/proxy/proxy_test.go create mode 100644 internal/util/errors/errors.go diff --git a/internal/proxy/mock_idler.go b/internal/proxy/mock_idler.go new file mode 100644 index 0000000..f46f05d --- /dev/null +++ b/internal/proxy/mock_idler.go @@ -0,0 +1,27 @@ +package proxy + +type mockIdler struct { + idlerAPI string + isIdle bool +} + +// IsIdle returns mockIdler.isIdle +func (i *mockIdler) IsIdle(tenant string, openShiftAPIURL string) (bool, error) { + return i.isIdle, nil +} + +// UnIdle always unidles (mock) +func (i *mockIdler) UnIdle(tenant string, openShiftAPIURL string) error { + return nil +} + +// Clusters returns a map which maps the OpenShift API URL to the application DNS for this cluster. An empty map together with +// an error is returned if an error occurs. +func (i *mockIdler) Clusters() (map[string]string, error) { + clusters := map[string]string{ + "https://api.free-stg.openshift.com/": "1b7d.free-stg.openshiftapps.com", + "https://api.starter-us-east-2a.openshift.com/": "b542.starter-us-east-2a.openshiftapps.com", + } + + return clusters, nil +} diff --git a/internal/proxy/mock_login.go b/internal/proxy/mock_login.go new file mode 100644 index 0000000..3c066c5 --- /dev/null +++ b/internal/proxy/mock_login.go @@ -0,0 +1,47 @@ +package proxy + +import ( + "net/http" + + "errors" + + log "github.com/sirupsen/logrus" +) + +type mockLogin struct { + isLoggedIn bool + isTokenValid bool + giveOSOToken bool +} + +// loginJenkins always logs in +func (l *mockLogin) loginJenkins(pci CacheItem, osoToken string, requestLogEntry *log.Entry) (int, []*http.Cookie, error) { + c := &http.Cookie{ + Name: "JSESSIONID", + Value: "Some Session ID", + } + return 200, []*http.Cookie{c}, nil +} + +func (l *mockLogin) processToken(tokenData []byte, requestLogEntry *log.Entry, p *Proxy) (pci *CacheItem, osioToken string, err error) { + + if !l.isTokenValid { + return nil, "", errors.New("Could not Process Token Properly") + } + pci = &CacheItem{ + ClusterURL: "https://api.free-stg.openshift.com/", + NS: "someNameSpace", + Route: "1b7d.free-stg.openshiftapps.com", + Scheme: "", + } + + return pci, "OSIO_TOKEN", nil +} + +func (l *mockLogin) GetOSOToken(authURL string, clusterURL string, token string) (osoToken string, err error) { + if l.giveOSOToken { + return "valid OSO Token", nil + } + + return "", errors.New("Could not get valid OSO Token") +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 531faec..ab239c0 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -17,11 +17,11 @@ import ( "errors" "hash/fnv" - "runtime" "github.com/fabric8-services/fabric8-jenkins-proxy/internal/clients" "github.com/fabric8-services/fabric8-jenkins-proxy/internal/configuration" "github.com/fabric8-services/fabric8-jenkins-proxy/internal/storage" + jsonerrors "github.com/fabric8-services/fabric8-jenkins-proxy/internal/util/errors" "github.com/fabric8-services/fabric8-jenkins-proxy/internal/util/logging" "github.com/patrickmn/go-cache" "github.com/satori/go.uuid" @@ -78,17 +78,7 @@ type Proxy struct { indexPath string maxRequestRetry int clusters map[string]string -} - -// Error represents list of error informations. -type Error struct { - Errors []ErrorInfo -} - -// ErrorInfo describes an HTTP error, consisting of HTTP status code and error detail. -type ErrorInfo struct { - Code string `json:"code"` - Detail string `json:"detail"` + loginInstance interfaceOflogin } // NewProxy creates an instance of Proxy client @@ -107,6 +97,7 @@ func NewProxy(tenant *clients.Tenant, wit *clients.WIT, idler clients.IdlerServi indexPath: config.GetIndexPath(), maxRequestRetry: config.GetMaxRequestRetry(), clusters: clusters, + loginInstance: &login{}, } //Collect and parse public key from Keycloak @@ -136,7 +127,7 @@ func (p *Proxy) Handle(w http.ResponseWriter, r *http.Request) { requestURL := logging.RequestMethodAndURL(r) requestHeaders := logging.RequestHeaders(r) - requestHash := p.createRequestHash(requestURL, requestHeaders) + requestHash := createRequestHash(requestURL, requestHeaders) logEntryWithHash := proxyLogger.WithField("request-hash", requestHash) logEntryWithHash.WithFields( @@ -192,193 +183,217 @@ func (p *Proxy) Handle(w http.ResponseWriter, r *http.Request) { }).ServeHTTP(w, r) } -func (p *Proxy) handleJenkinsUIRequest(w http.ResponseWriter, r *http.Request, requestLogEntry *log.Entry) (cacheKey string, ns string, noProxy bool) { - //redirect determines if we need to redirect to auth service +func (p *Proxy) handleJenkinsUIRequest(responseWriter http.ResponseWriter, request *http.Request, requestLogEntry *log.Entry) (cacheKey string, namespace string, noProxy bool) { + needsAuth := true noProxy = true - //redirectURL is used for auth service as a target of successful auth redirect - redirectURL, err := url.ParseRequestURI(fmt.Sprintf("%s%s", strings.TrimRight(p.redirect, "/"), r.URL.Path)) + // Checks if token_json is present in the query + if tokenJSON, ok := request.URL.Query()["token_json"]; ok { + namespace = p.processAuthenticatedRequest(responseWriter, request, requestLogEntry, tokenJSON, &noProxy) + } + + if len(request.Cookies()) > 0 { + cacheKey, namespace, noProxy, needsAuth = p.checkCookies(responseWriter, request, requestLogEntry) + } + //Check if we need to redirect to auth service + if needsAuth { + p.redirectToAuth(responseWriter, request, requestLogEntry) + } + + return cacheKey, namespace, noProxy +} + +func (p *Proxy) redirectToAuth(responseWriter http.ResponseWriter, request *http.Request, requestLogEntry *log.Entry) { + // redirectURL is used for auth service as a target of successful auth redirect + redirectURL, err := url.ParseRequestURI(fmt.Sprintf("%s%s", strings.TrimRight(p.redirect, "/"), request.URL.Path)) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(responseWriter, err, requestLogEntry) return } - //If the user provides OSO token, we can directly proxy - if _, ok := r.Header["Authorization"]; ok { //FIXME Do we need this? - needsAuth = false + redirAuth := GetAuthURI(p.authURL, redirectURL.String()) + requestLogEntry.Infof("Redirecting to auth: %s", redirAuth) + http.Redirect(responseWriter, request, redirAuth, 301) +} + +// processAuthenticatedRequest processes token_json if present in query and find user info and login to Jenkins and redirects the target page +func (p *Proxy) processAuthenticatedRequest(responseWriter http.ResponseWriter, request *http.Request, requestLogEntry *log.Entry, tokenJSON []string, noProxy *bool) (namespace string) { + + // redirectURL is used for auth service as a target of successful auth redirect + redirectURL, err := url.ParseRequestURI(fmt.Sprintf("%s%s", strings.TrimRight(p.redirect, "/"), request.URL.Path)) + if err != nil { + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + return namespace } - if tj, ok := r.URL.Query()["token_json"]; ok { //If there is token_json in query, process it, find user info and login to Jenkins - if len(tj) < 1 { - p.HandleError(w, errors.New("could not read JWT token from URL"), requestLogEntry) - return - } + if len(tokenJSON) < 1 { + jsonerrors.JSONError(responseWriter, errors.New("could not read JWT token from URL"), requestLogEntry) + return namespace + } - pci, osioToken, err := p.processToken([]byte(tj[0]), requestLogEntry) - if err != nil { - p.HandleError(w, err, requestLogEntry) - return - } - ns = pci.NS - clusterURL := pci.ClusterURL - requestLogEntry.WithFields(log.Fields{"ns": ns, "cluster": clusterURL}).Debug("Found token info in query") + proxyCacheItem, osioToken, err := p.loginInstance.processToken([]byte(tokenJSON[0]), requestLogEntry, p) + if err != nil { + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + return namespace + } - isIdle, err := p.idler.IsIdle(ns, clusterURL) - if err != nil { - p.HandleError(w, err, requestLogEntry) - return - } + namespace = proxyCacheItem.NS + clusterURL := proxyCacheItem.ClusterURL + requestLogEntry.WithField("ns", namespace).Debug("Found token info in query") - //Break the process if the Jenkins is idled, set a cookie and redirect to self - if isIdle { - err = p.idler.UnIdle(ns, clusterURL) - if err != nil { - p.HandleError(w, err, requestLogEntry) - return - } + // Check if jenkins is idled + isIdle, err := p.idler.IsIdle(namespace, clusterURL) + if err != nil { + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + return namespace - p.setIdledCookie(w, pci) - requestLogEntry.WithField("ns", ns).Info("Redirecting to remove token from URL") - http.Redirect(w, r, redirectURL.String(), http.StatusFound) //Redirect to get rid of token in URL - return - } + } - osoToken, err := GetOSOToken(p.authURL, pci.ClusterURL, osioToken) + // Break the process if the Jenkins is idled, set a cookie and redirect to self + if isIdle { + // Initiates unidling of the Jenkins instance + err = p.idler.UnIdle(namespace, clusterURL) if err != nil { - p.HandleError(w, err, requestLogEntry) - return + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + return namespace } - requestLogEntry.WithField("ns", ns).Debug("Loaded OSO token") - statusCode, cookies, err := p.loginJenkins(pci, osoToken, requestLogEntry) - if err != nil { - p.HandleError(w, err, requestLogEntry) - return - } - if statusCode == http.StatusOK { - for _, cookie := range cookies { - if cookie.Name == CookieJenkinsIdled { - continue - } - http.SetCookie(w, cookie) - if strings.HasPrefix(cookie.Name, SessionCookie) { //Find session cookie and use it's value as a key for cache - p.ProxyCache.SetDefault(cookie.Value, pci) - requestLogEntry.WithField("ns", ns).Infof("Cached Jenkins route %s in %s", pci.Route, cookie.Value) - requestLogEntry.WithField("ns", ns).Infof("Redirecting to %s", redirectURL.String()) - //If all good, redirect to self to remove token from url - http.Redirect(w, r, redirectURL.String(), http.StatusFound) - return - } + // sets a cookie, in which cookie value is id of proxyCacheItem in cache + p.setIdledCookie(responseWriter, *proxyCacheItem) + requestLogEntry.WithField("ns", namespace).Info("Redirecting to remove token from URL") + // Redirect to get rid of token in URL + http.Redirect(responseWriter, request, redirectURL.String(), http.StatusFound) + return namespace + } + // The part below here is executed only if Jenkins is not idle + + // gets openshift online token + osoToken, err := p.loginInstance.GetOSOToken(p.authURL, clusterURL, osioToken) + if err != nil { + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + return namespace + } + requestLogEntry.WithField("ns", namespace).Debug("Loaded OSO token") + + // login to Jenkins and gets cookies + statusCode, cookies, err := p.loginInstance.loginJenkins(*proxyCacheItem, osoToken, requestLogEntry) + if err != nil { + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + return namespace + } - //If we got here, the cookie was not found - report error - p.HandleError(w, fmt.Errorf("could not find cookie %s for %s", SessionCookie, pci.NS), requestLogEntry) + if statusCode == http.StatusOK { + // sets all cookies that we got from loginJenkins method + for _, cookie := range cookies { + // No need to set a cookie for whether jenkins if idled, because we have already done that + if cookie.Name == CookieJenkinsIdled { + continue } - } else { - p.HandleError(w, fmt.Errorf("could not login to Jenkins in %s namespace", ns), requestLogEntry) + http.SetCookie(responseWriter, cookie) + if strings.HasPrefix(cookie.Name, SessionCookie) { + // Find session cookie and use it's value as a key for cache + p.ProxyCache.SetDefault(cookie.Value, proxyCacheItem) + requestLogEntry.WithField("ns", namespace).Infof("Cached Jenkins route %s in %s", proxyCacheItem.Route, cookie.Value) + requestLogEntry.WithField("ns", namespace).Infof("Redirecting to %s", redirectURL.String()) + //If all good, redirect to self to remove token from url + http.Redirect(responseWriter, request, redirectURL.String(), http.StatusFound) + return namespace + } + + //If we got here, the cookie was not found - report error + jsonerrors.JSONError(responseWriter, fmt.Errorf("could not find cookie %s for %s", SessionCookie, proxyCacheItem.NS), requestLogEntry) } + } else { + jsonerrors.JSONError(responseWriter, fmt.Errorf("could not login to Jenkins in %s namespace", namespace), requestLogEntry) } + return namespace +} - if len(r.Cookies()) > 0 { //Check cookies and proxy cache to find user info - for _, cookie := range r.Cookies() { - cacheVal, ok := p.ProxyCache.Get(cookie.Value) - if !ok { - continue +// checkCookies checks cookies and proxy cache to find user info +func (p *Proxy) checkCookies(responseWriter http.ResponseWriter, request *http.Request, requestLogEntry *log.Entry) (cacheKey string, namespace string, noProxy bool, needsAuth bool) { + needsAuth = true + noProxy = true + + for _, cookie := range request.Cookies() { + cacheVal, ok := p.ProxyCache.Get(cookie.Value) + if !ok { + continue + } + if strings.HasPrefix(cookie.Name, SessionCookie) { // We found a session cookie in cache + cacheKey = cookie.Value + proxyCacheItem := cacheVal.(CacheItem) + request.Host = proxyCacheItem.Route // Configure proxy upstream + request.URL.Host = proxyCacheItem.Route + request.URL.Scheme = proxyCacheItem.Scheme + namespace = proxyCacheItem.NS + needsAuth = false // user is logged in, do not redirect + noProxy = false + break + } else if cookie.Name == CookieJenkinsIdled { // Found a cookie saying Jenkins is idled, verify and act accordingly + cacheKey = cookie.Value + needsAuth = false + proxyCacheItem := cacheVal.(CacheItem) + namespace = proxyCacheItem.NS + clusterURL := proxyCacheItem.ClusterURL + isIdle, err := p.idler.IsIdle(proxyCacheItem.NS, clusterURL) + if err != nil { + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + return cacheKey, namespace, noProxy, needsAuth } - if strings.HasPrefix(cookie.Name, SessionCookie) { //We found a session cookie in cache - cacheKey = cookie.Value - pci := cacheVal.(CacheItem) - r.Host = pci.Route //Configure proxy upstream - r.URL.Host = pci.Route - r.URL.Scheme = pci.Scheme - ns = pci.NS - needsAuth = false //user is probably logged in, do not redirect - noProxy = false - break - } else if cookie.Name == CookieJenkinsIdled { //Found a cookie saying Jenkins is idled, verify and act accordingly - cacheKey = cookie.Value - needsAuth = false - pci := cacheVal.(CacheItem) - ns = pci.NS - clusterURL := pci.ClusterURL - isIdle, err := p.idler.IsIdle(ns, clusterURL) + + if isIdle { //If jenkins is idled, return loading page and status 202 + err = p.idler.UnIdle(namespace, clusterURL) if err != nil { - p.HandleError(w, err, requestLogEntry) - return - } - if isIdle { //If jenkins is idled, return loading page and status 202 - err = p.idler.UnIdle(ns, clusterURL) - if err != nil { - p.HandleError(w, err, requestLogEntry) - return - } - err = p.processTemplate(w, ns, requestLogEntry) - p.recordStatistics(pci.NS, time.Now().Unix(), 0) //FIXME - maybe do this at the beginning? - } else { //If Jenkins is running, remove the cookie - //OpenShift can take up to couple tens of second to update HAProxy configuration for new route - //so even if the pod is up, route might still return 500 - i.e. we need to check the route - //before claiming Jenkins is up - var statusCode int - statusCode, _, err = p.loginJenkins(pci, "", requestLogEntry) - if err != nil { - p.HandleError(w, err, requestLogEntry) - return - } - if statusCode == 200 || statusCode == 403 { - cookie.Expires = time.Unix(0, 0) - http.SetCookie(w, cookie) - } else { - err = p.processTemplate(w, ns, requestLogEntry) - } + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + return cacheKey, namespace, noProxy, needsAuth } + err = p.processTemplate(responseWriter, namespace, requestLogEntry) + if err != nil { + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + } + p.recordStatistics(proxyCacheItem.NS, time.Now().Unix(), 0) //FIXME - maybe do this at the beginning? + } else { //If Jenkins is running, remove the cookie + //OpenShift can take up to couple tens of second to update HAProxy configuration for new route + //so even if the pod is up, route might still return 500 - i.e. we need to check the route + //before claiming Jenkins is up + var statusCode int + statusCode, _, err = p.loginInstance.loginJenkins(proxyCacheItem, "", requestLogEntry) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + return cacheKey, namespace, noProxy, needsAuth + } + if statusCode == 200 || statusCode == 403 { + cookie.Expires = time.Unix(0, 0) + http.SetCookie(responseWriter, cookie) + } else { + err = p.processTemplate(responseWriter, namespace, requestLogEntry) + if err != nil { + jsonerrors.JSONError(responseWriter, err, requestLogEntry) + } } - break } + break } - if len(cacheKey) == 0 { //If we do not have user's info cached, run through login process to get it - requestLogEntry.WithField("ns", ns).Info("Could not find cache, redirecting to re-login") - } else { - requestLogEntry.WithField("ns", ns).Infof("Found cookie %s", cacheKey) - } - } - - //Check if we need to redirect to auth service - if needsAuth { - redirAuth := GetAuthURI(p.authURL, redirectURL.String()) - requestLogEntry.Infof("Redirecting to auth: %s", redirAuth) - http.Redirect(w, r, redirAuth, 301) } - return -} -func (p *Proxy) loginJenkins(pci CacheItem, osoToken string, requestLogEntry *log.Entry) (int, []*http.Cookie, error) { - //Login to Jenkins with OSO token to get cookies - jenkinsURL := fmt.Sprintf("%s://%s/securityRealm/commenceLogin?from=%%2F", pci.Scheme, pci.Route) - req, _ := http.NewRequest("GET", jenkinsURL, nil) - if len(osoToken) > 0 { - requestLogEntry.WithField("ns", pci.NS).Infof("Jenkins login for %s", jenkinsURL) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", osoToken)) + if len(cacheKey) == 0 { //If we do not have user's info cached, run through login process to get it + requestLogEntry.WithField("ns", namespace).WithField("needsAuth", needsAuth).Info("Could not find cache, redirecting to re-login") } else { - requestLogEntry.WithField("ns", pci.NS).Infof("Accessing Jenkins route %s", jenkinsURL) - } - c := http.DefaultClient - resp, err := c.Do(req) - if err != nil { - return 0, nil, err + requestLogEntry.WithField("ns", namespace).Infof("Found cookie %s", cacheKey) } - defer resp.Body.Close() - return resp.StatusCode, resp.Cookies(), err + + return cacheKey, namespace, noProxy, needsAuth } -func (p *Proxy) setIdledCookie(w http.ResponseWriter, pci CacheItem) { +func (p *Proxy) setIdledCookie(w http.ResponseWriter, proxyCacheItem CacheItem) { c := &http.Cookie{} - u1 := uuid.NewV4().String() + id := uuid.NewV4().String() c.Name = CookieJenkinsIdled - c.Value = u1 - p.ProxyCache.SetDefault(u1, pci) + c.Value = id + // Store proxyCacheItem at id in cache + p.ProxyCache.SetDefault(id, proxyCacheItem) http.SetCookie(w, c) return } @@ -391,13 +406,13 @@ func (p *Proxy) handleGitHubRequest(w http.ResponseWriter, r *http.Request, requ body, err := ioutil.ReadAll(r.Body) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(w, err, requestLogEntry) return } err = json.Unmarshal(body, &gh) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(w, err, requestLogEntry) return } @@ -408,12 +423,12 @@ func (p *Proxy) handleGitHubRequest(w http.ResponseWriter, r *http.Request, requ clusterURL := namespace.ClusterURL requestLogEntry.WithFields(log.Fields{"ns": ns, "cluster": clusterURL, "repository": gh.Repository.CloneURL}).Info("Processing GitHub request ") if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(w, err, requestLogEntry) return } route, scheme, err := p.constructRoute(namespace.ClusterURL, namespace.Name) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(w, err, requestLogEntry) return } @@ -423,7 +438,7 @@ func (p *Proxy) handleGitHubRequest(w http.ResponseWriter, r *http.Request, requ isIdle, err := p.idler.IsIdle(ns, clusterURL) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(w, err, requestLogEntry) return } @@ -432,7 +447,7 @@ func (p *Proxy) handleGitHubRequest(w http.ResponseWriter, r *http.Request, requ p.storeGHRequest(w, r, ns, body, requestLogEntry) err = p.idler.UnIdle(ns, clusterURL) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(w, err, requestLogEntry) } return } @@ -448,17 +463,17 @@ func (p *Proxy) storeGHRequest(w http.ResponseWriter, r *http.Request, ns string w.Header().Set("Server", "Webhook-Proxy") sr, err := storage.NewRequest(r, ns, body) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(w, err, requestLogEntry) return } err = p.storageService.CreateRequest(sr) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(w, err, requestLogEntry) return } err = p.recordStatistics(ns, 0, time.Now().Unix()) if err != nil { - p.HandleError(w, err, requestLogEntry) + jsonerrors.JSONError(w, err, requestLogEntry) return } @@ -487,76 +502,6 @@ func (p *Proxy) processTemplate(w http.ResponseWriter, ns string, requestLogEntr return } -func (p *Proxy) processToken(tokenData []byte, requestLogEntry *log.Entry) (pci CacheItem, osioToken string, err error) { - tokenJSON := &TokenJSON{} - err = json.Unmarshal(tokenData, tokenJSON) - if err != nil { - return - } - - uid, err := GetTokenUID(tokenJSON.AccessToken, p.publicKey) - if err != nil { - return - } - - ti, err := p.tenant.GetTenantInfo(uid) - if err != nil { - return - } - osioToken = tokenJSON.AccessToken - - namespace, err := p.tenant.GetNamespaceByType(ti, ServiceName) - if err != nil { - return - } - - requestLogEntry.WithField("ns", namespace.Name).Debug("Extracted information from token") - route, scheme, err := p.constructRoute(namespace.ClusterURL, namespace.Name) - if err != nil { - return - } - - //Prepare an item for proxyCache - Jenkins info and OSO token - pci = NewCacheItem(namespace.Name, scheme, route, namespace.ClusterURL) - - return -} - -//HandleError creates a JSON response with a given error and writes it to ResponseWriter -func (p *Proxy) HandleError(w http.ResponseWriter, err error, requestLogEntry *log.Entry) { - // log the error - location := "" - if err != nil { - pc, fn, line, _ := runtime.Caller(1) - - location = fmt.Sprintf(" %s[%s:%d]", runtime.FuncForPC(pc).Name(), fn, line) - } - - requestLogEntry.WithFields( - log.Fields{ - "location": location, - "error": err, - }).Error("Error Handling proxy request request.") - - // create error response - w.WriteHeader(http.StatusInternalServerError) - - pei := ErrorInfo{ - Code: fmt.Sprintf("%d", http.StatusInternalServerError), - Detail: err.Error(), - } - e := Error{ - Errors: make([]ErrorInfo, 1), - } - e.Errors[0] = pei - - eb, err := json.Marshal(e) - if err != nil { - requestLogEntry.Error(err) - } - w.Write(eb) -} - func (p *Proxy) isGitHubRequest(r *http.Request) bool { isGH := false // Is the request coming from Github Webhook? @@ -567,7 +512,7 @@ func (p *Proxy) isGitHubRequest(r *http.Request) bool { return isGH } -func (p *Proxy) createRequestHash(url string, headers string) uint32 { +func createRequestHash(url string, headers string) uint32 { h := fnv.New32a() h.Write([]byte(url + headers + fmt.Sprint(time.Now()))) return h.Sum32() @@ -610,13 +555,13 @@ func (p *Proxy) getUser(repositoryCloneURL string, logEntry *log.Entry) (clients } //RecordStatistics writes usage statistics to a database -func (p *Proxy) recordStatistics(ns string, la int64, lbf int64) (err error) { - log.WithField("ns", ns).Debug("Recording stats") - s, notFound, err := p.storageService.GetStatisticsUser(ns) +func (p *Proxy) recordStatistics(namespace string, lastAccessed int64, lastBufferedRequest int64) (err error) { + log.WithField("namespace", namespace).Debug("Recording stats") + s, notFound, err := p.storageService.GetStatisticsUser(namespace) if err != nil { log.WithFields( log.Fields{ - "ns": ns, + "namespace": namespace, }).Warningf("Could not load statistics: %s", err) if !notFound { return @@ -624,28 +569,28 @@ func (p *Proxy) recordStatistics(ns string, la int64, lbf int64) (err error) { } if notFound { - log.WithField("ns", ns).Infof("New user %s", ns) - s = storage.NewStatistics(ns, la, lbf) + log.WithField("namespace", namespace).Infof("New user %s", namespace) + s = storage.NewStatistics(namespace, lastAccessed, lastBufferedRequest) err = p.storageService.CreateStatistics(s) if err != nil { - log.Errorf("Could not create statistics for %s: %s", ns, err) + log.Errorf("Could not create statistics for %s: %s", namespace, err) } return } - if la != 0 { - s.LastAccessed = la + if lastAccessed != 0 { + s.LastAccessed = lastAccessed } - if lbf != 0 { - s.LastBufferedRequest = lbf + if lastBufferedRequest != 0 { + s.LastBufferedRequest = lastBufferedRequest } p.visitLock.Lock() err = p.storageService.UpdateStatistics(s) p.visitLock.Unlock() if err != nil { - log.WithField("ns", ns).Errorf("Could not update statistics for %s: %s", ns, err) + log.WithField("namespace", namespace).Errorf("Could not update statistics for %s: %s", namespace, err) } return @@ -750,3 +695,109 @@ func (p *Proxy) ProcessBuffer() { time.Sleep(p.bufferCheckSleep * time.Second) } } + +type interfaceOflogin interface { + loginJenkins(pci CacheItem, osoToken string, requestLogEntry *log.Entry) (int, []*http.Cookie, error) + processToken(tokenData []byte, requestLogEntry *log.Entry, p *Proxy) (pci *CacheItem, osioToken string, err error) + GetOSOToken(authURL string, clusterURL string, token string) (osoToken string, err error) +} + +type login struct { +} + +func (l *login) loginJenkins(pci CacheItem, osoToken string, requestLogEntry *log.Entry) (int, []*http.Cookie, error) { + //Login to Jenkins with OSO token to get cookies + jenkinsURL := fmt.Sprintf("%s://%s/securityRealm/commenceLogin?from=%%2F", pci.Scheme, pci.Route) + req, _ := http.NewRequest("GET", jenkinsURL, nil) + if len(osoToken) > 0 { + requestLogEntry.WithField("ns", pci.NS).Infof("Jenkins login for %s", jenkinsURL) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", osoToken)) + } else { + requestLogEntry.WithField("ns", pci.NS).Infof("Accessing Jenkins route %s", jenkinsURL) + } + c := http.DefaultClient + resp, err := c.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + return resp.StatusCode, resp.Cookies(), err +} + +// Extract openshift.io token (access_token from token_json) +// and get proxy cache item using openshift.io token +func (l *login) processToken(tokenData []byte, requestLogEntry *log.Entry, p *Proxy) (proxyCacheItem *CacheItem, osioToken string, err error) { + tokenJSON := &TokenJSON{} + err = json.Unmarshal(tokenData, tokenJSON) + if err != nil { + return + } + + uid, err := GetTokenUID(tokenJSON.AccessToken, p.publicKey) + if err != nil { + return + } + + ti, err := p.tenant.GetTenantInfo(uid) + if err != nil { + return + } + osioToken = tokenJSON.AccessToken + + namespace, err := p.tenant.GetNamespaceByType(ti, ServiceName) + if err != nil { + return + } + + requestLogEntry.WithField("ns", namespace.Name).Debug("Extracted information from token") + route, scheme, err := p.constructRoute(namespace.ClusterURL, namespace.Name) + if err != nil { + return + } + + //Prepare an item for proxyCache - Jenkins info and OSO token + pci := NewCacheItem(namespace.Name, scheme, route, namespace.ClusterURL) + proxyCacheItem = &pci + + return +} + +// GetOSOToken returns Openshift online token on giving raw JWT token, cluster URL and auth service url as input. +func (l *login) GetOSOToken(authURL string, clusterURL string, token string) (osoToken string, err error) { + url := fmt.Sprintf("%s/api/token?for=%s", strings.TrimRight(authURL, "/"), clusterURL) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + c := http.DefaultClient + + resp, err := c.Do(req) + if err != nil { + return + } + + tj := &TokenJSON{} + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return + } + err = json.Unmarshal(body, tj) + if err != nil { + return + } + + if len(tj.Errors) > 0 { + err = fmt.Errorf(tj.Errors[0].Detail) + return + } + + if len(tj.AccessToken) > 0 { + osoToken = tj.AccessToken + } else { + err = fmt.Errorf("OSO access token empty for %s", authURL) + } + return +} diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go new file mode 100644 index 0000000..229a7af --- /dev/null +++ b/internal/proxy/proxy_test.go @@ -0,0 +1,267 @@ +package proxy + +import ( + "database/sql" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/fabric8-services/fabric8-jenkins-proxy/internal/clients" + "github.com/fabric8-services/fabric8-jenkins-proxy/internal/storage" + "github.com/fabric8-services/fabric8-jenkins-proxy/internal/testutils/mock" + _ "github.com/lib/pq" + uuid "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + dockertest "gopkg.in/ory-am/dockertest.v3" +) + +func Test_constructRoute(t *testing.T) { + proxy := Proxy{ + clusters: map[string]string{ + "https://api.free-stg.openshift.com/": "1b7d.free-stg.openshiftapps.com", + "https://api.starter-us-east-2a.openshift.com/": "b542.starter-us-east-2a.openshiftapps.com", + }, + } + + // Successful case + expectedRoute := "jenkins-sampleNamespace.1b7d.free-stg.openshiftapps.com" + retrievedRoute, scheme, err := proxy.constructRoute("https://api.free-stg.openshift.com/", "sampleNamespace") + + assert.Nil(t, err) + assert.Equal(t, expectedRoute, retrievedRoute) + assert.Equal(t, "https", scheme) + + // Failing case + retrievedRoute, scheme, err = proxy.constructRoute("some_invalid_url", "sampleNamespace") + + assert.NotNil(t, err) + assert.Equal(t, "could not find entry for cluster some_invalid_url", err.Error()) + assert.Equal(t, "", retrievedRoute) + assert.Equal(t, "", scheme) +} + +func Test_recordStatistics(t *testing.T) { + proxy, pool, resource := setupDependencyServices(true) + assert.NotNil(t, proxy) + defer pool.Purge(resource) + // Let's try to record a statistics that doesn't exist in our database, + // i.e., given namespace doesn't exist in the db + err := proxy.recordStatistics("namespace_that_doesnt_exist", 123, 123) + assert.Nil(t, err) + // Check if data has been saved in db + statistics, notFound, err := proxy.storageService.GetStatisticsUser("namespace_that_doesnt_exist") + assert.Nil(t, err) + assert.False(t, notFound) + assert.Equal(t, int64(123), statistics.LastAccessed) + assert.Equal(t, int64(123), statistics.LastBufferedRequest) + + // It should update if namespace of statistics is found, but lastAccessed or LastBufferedRequest are different + err = proxy.recordStatistics("namespace_that_doesnt_exist", int64(123), int64(165)) + assert.Nil(t, err) + // Check if data has been saved in db + statistics, notFound, err = proxy.storageService.GetStatisticsUser("namespace_that_doesnt_exist") + assert.Nil(t, err) + assert.False(t, notFound) + assert.Equal(t, int64(123), statistics.LastAccessed) + assert.Equal(t, int64(165), statistics.LastBufferedRequest) +} + +func Test_checkCookies(t *testing.T) { + proxy, pool, resource := setupDependencyServices(true) + assert.NotNil(t, proxy) + defer pool.Purge(resource) + + req := httptest.NewRequest("GET", "/doesnt_matter", nil) + w := httptest.NewRecorder() + proxyLogger := log.WithFields(log.Fields{"component": "proxy"}) + + // Scenario 1: User info is not cached , i.e., cookie not found needs to relogin + proxy.loginInstance = &mockLogin{} + cacheKey, namespace, noProxy, needsAuth := proxy.checkCookies(w, req, proxyLogger) + assert.True(t, needsAuth) + assert.True(t, noProxy) + assert.Empty(t, cacheKey) + assert.Empty(t, namespace) + + // Scenario 2: only jenkins idle cookie is found, user needs to login, and info will be available + cacheItem := CacheItem{ + ClusterURL: "https://api.free-stg.openshift.com/", + NS: "someNameSpace", + Route: "1b7d.free-stg.openshiftapps.com", + Scheme: "", + } + + c := &http.Cookie{} + id := uuid.NewV4().String() + c.Name = CookieJenkinsIdled + c.Value = id + // Store proxyCacheItem at id in cache + proxy.ProxyCache.SetDefault(id, cacheItem) + req.AddCookie(c) + // lets mock login user + proxy.loginInstance = &mockLogin{} + cacheKey, namespace, noProxy, needsAuth = proxy.checkCookies(w, req, proxyLogger) + assert.False(t, needsAuth) + assert.True(t, noProxy) + assert.Equal(t, id, cacheKey) + assert.Equal(t, cacheItem.NS, namespace) + + // Scenario 3: If we find session cookie in the cache, user does'nt need to login, info is available + c.Name = SessionCookie + req = httptest.NewRequest("GET", "/doesnt_matter", nil) + req.AddCookie(c) + proxy.loginInstance = &login{} + cacheKey, namespace, noProxy, needsAuth = proxy.checkCookies(w, req, proxyLogger) + assert.False(t, needsAuth) + assert.False(t, noProxy) + assert.Equal(t, id, cacheKey) + assert.Equal(t, cacheItem.NS, namespace) + +} + +func Test_processAuthenticatedRequest(t *testing.T) { + proxy, pool, resource := setupDependencyServices(true) + assert.NotNil(t, proxy) + defer pool.Purge(resource) + + req := httptest.NewRequest("GET", "/doesnt_matter", nil) + w := httptest.NewRecorder() + proxyLogger := log.WithFields(log.Fields{"component": "proxy"}) + + // Scenario 1: fails with empty tokenJSON + noProxy := true + tokenJSON := []string{} + namespace := proxy.processAuthenticatedRequest(w, req, proxyLogger, tokenJSON, &noProxy) + assert.Empty(t, namespace) + assert.Equal(t, 500, w.Code) + assert.Contains(t, w.Body.String(), "could not read JWT token from URL") + + // Scenario 2: fails if tokenJSON has a an invalid token + tokenJSON = []string{"someinvalid_token"} + w = httptest.NewRecorder() + namespace = proxy.processAuthenticatedRequest(w, req, proxyLogger, tokenJSON, &noProxy) + assert.Empty(t, namespace) + assert.Equal(t, 500, w.Code) + assert.Contains(t, w.Body.String(), "invalid character 's' looking for beginning of value") + + // Scenario 3: With Valid tokenJSON and Jenkins is idle, jenkins idle cookie is set + // Mock processToken + proxy.loginInstance = &mockLogin{ + isLoggedIn: true, + isTokenValid: true, + } + proxy.idler = &mockIdler{ + isIdle: true, + } + tokenJSON = []string{"valid_token"} + w = httptest.NewRecorder() + namespace = proxy.processAuthenticatedRequest(w, req, proxyLogger, tokenJSON, &noProxy) + assert.Equal(t, "someNameSpace", namespace) + assert.Equal(t, 302, w.Code) + assert.Contains(t, w.Body.String(), "Found") + // How do I test if cookies are set? + + // Scenario 4: With Valid tokenJSON and Jenkins is not idle, jenkins idle cookie is set + // Mock processToken, GetOSOToken, loginJenkins + proxy.loginInstance = &mockLogin{ + isLoggedIn: true, + isTokenValid: true, + giveOSOToken: true, + } + proxy.idler = &mockIdler{ + isIdle: false, + } + tokenJSON = []string{"valid_token"} + w = httptest.NewRecorder() + namespace = proxy.processAuthenticatedRequest(w, req, proxyLogger, tokenJSON, &noProxy) + assert.Equal(t, "someNameSpace", namespace) + assert.Equal(t, 302, w.Code) + assert.Contains(t, w.Body.String(), "Found") +} + +func setupTestDatabase(mockConfig *mock.Config) (*dockertest.Pool, *dockertest.Resource, error) { + + pool, err := dockertest.NewPool("") + if err != nil { + log.Errorf("Could not connect to docker: %s", err) + return nil, nil, err + } + + resource, err := pool.Run("postgres", "9.6", []string{"POSTGRES_PASSWORD=" + mockConfig.GetPostgresPassword(), "POSTGRES_DB=" + mockConfig.GetPostgresDatabase()}) + if err != nil { + log.Errorf("Could not start resource: %s", err) + return nil, nil, err + } + + if err = pool.Retry(func() error { + var err error + db, err := sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@localhost:%s/%s?sslmode=disable", mockConfig.GetPostgresUser(), mockConfig.GetPostgresPassword(), resource.GetPort("5432/tcp"), mockConfig.GetPostgresDatabase())) + if err != nil { + return err + } + return db.Ping() + }); err != nil { + log.Errorf("Could not connect to docker: %s", err) + err1 := pool.Purge(resource) + if err != nil { + log.Fatalf("Could not remove resource: %s", err1) + } + return nil, nil, err + } + + port, _ := strconv.Atoi(resource.GetPort("5432/tcp")) + mockConfig.PostgresPort = port + + return pool, resource, nil +} + +func setupDependencyServices(needDB bool) (Proxy, *dockertest.Pool, *dockertest.Resource) { + configuration := mock.NewConfig() + var pool *dockertest.Pool + var resource *dockertest.Resource + var err error + if needDB { + pool, resource, err = setupTestDatabase(&configuration) + if err != nil { + panic("invalid test " + err.Error()) + } + } + + configuration.IdlerURL = "doesnt_matter" + + // Connect to DB + db, err := storage.Connect(&configuration) + if err != nil { + panic("invalid test " + err.Error()) + } + + store := storage.NewDBStorage(db) + + // Create tenant client + tenant := clients.NewTenant(configuration.GetTenantURL(), configuration.GetAuthToken()) + + // Create WorkItemTracker client + wit := clients.NewWIT(configuration.GetWitURL(), configuration.GetAuthToken()) + + // Create Idler client + idler := mockIdler{ + idlerAPI: configuration.IdlerURL, + isIdle: false, + } + + // Get the cluster view from the Idler + clusters := map[string]string{ + "https://api.free-stg.openshift.com/": "1b7d.free-stg.openshiftapps.com", + "https://api.starter-us-east-2a.openshift.com/": "b542.starter-us-east-2a.openshiftapps.com", + } + + proxy, err := NewProxy(&tenant, &wit, &idler, store, &configuration, clusters) + if err != nil { + panic(err.Error()) + } + + return proxy, pool, resource +} diff --git a/internal/proxy/token.go b/internal/proxy/token.go index 96f2c2d..bb6ed0b 100644 --- a/internal/proxy/token.go +++ b/internal/proxy/token.go @@ -10,6 +10,7 @@ import ( "strings" jwt "github.com/dgrijalva/jwt-go" + "github.com/fabric8-services/fabric8-jenkins-proxy/internal/util/errors" ) // TokenJSON represents a JSON Web Token @@ -19,7 +20,7 @@ type TokenJSON struct { TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` RefreshExpiresIn int `json:"refresh_expires_in"` - Errors []ErrorInfo + Errors []errors.ErrorInfo } // GetTokenUID gets user identity on giving raw JWT token and public key of auth service as input. @@ -46,46 +47,6 @@ func GetTokenUID(token string, pk *rsa.PublicKey) (sub string, err error) { return } -// GetOSOToken returns Openshift online token on giving raw JWT token, cluster URL and auth service url as input. -func GetOSOToken(authURL string, clusterURL string, token string) (osoToken string, err error) { - url := fmt.Sprintf("%s/api/token?for=%s", strings.TrimRight(authURL, "/"), clusterURL) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - c := http.DefaultClient - - resp, err := c.Do(req) - if err != nil { - return - } - - tj := &TokenJSON{} - - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return - } - err = json.Unmarshal(body, tj) - if err != nil { - return - } - - if len(tj.Errors) > 0 { - err = fmt.Errorf(tj.Errors[0].Detail) - return - } - - if len(tj.AccessToken) > 0 { - osoToken = tj.AccessToken - } else { - err = fmt.Errorf("OSO access token empty for %s", authURL) - } - return -} - // GetPublicKey gets public key of keycloak realm which Proxy service is using. func GetPublicKey(kcURL string) (pk *rsa.PublicKey, err error) { resp, err := http.Get(fmt.Sprintf("%s/auth/realms/fabric8/", strings.TrimRight(kcURL, "/"))) diff --git a/internal/util/errors/errors.go b/internal/util/errors/errors.go new file mode 100644 index 0000000..bc61738 --- /dev/null +++ b/internal/util/errors/errors.go @@ -0,0 +1,60 @@ +package errors + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime" + + log "github.com/sirupsen/logrus" +) + +// JSONError creates a JSON response with a given error and writes it to ResponseWriter +func JSONError(w http.ResponseWriter, err error, requestLogEntry *log.Entry) { + // log the error + var location string + if err != nil { + programCounter, fileName, lineNumber, ok := runtime.Caller(1) + if ok == false { + log.Errorf("It was not possible to recover the information from runtime.Caller()") + } + + location = fmt.Sprintf(" %s[%s:%d]", runtime.FuncForPC(programCounter).Name(), fileName, lineNumber) + } + + requestLogEntry.WithFields( + log.Fields{ + "location": location, + "error": err, + }).Error("Error Handling proxy request.") + + // create error response + w.WriteHeader(http.StatusInternalServerError) + + e := Error{ + Errors: make([]ErrorInfo, 1), + } + + e.Errors[0] = ErrorInfo{ + Code: fmt.Sprintf("%d", http.StatusInternalServerError), + Detail: err.Error(), + } + + errorBody, err := json.Marshal(e) + if err != nil { + requestLogEntry.Error(err) + } + w.Write(errorBody) + +} + +// Error represents list of error informations. +type Error struct { + Errors []ErrorInfo +} + +// ErrorInfo describes an HTTP error, consisting of HTTP status code and error detail. +type ErrorInfo struct { + Code string `json:"code"` + Detail string `json:"detail"` +}