19963: Enable login flow without anonymous token configured.
authorTom Clegg <tom@curii.com>
Thu, 23 Feb 2023 15:45:14 +0000 (10:45 -0500)
committerTom Clegg <tom@curii.com>
Thu, 23 Feb 2023 15:45:14 +0000 (10:45 -0500)
Response is now 401 (was 404) when the requested collection is not
even loaded because the token is either invalid or passed in an
unsupported way (e.g., in an Authorization header with a
public-data-only URL path).

This revealed some testing issues, e.g., Test404 was attempting to
check that authenticated requests for nonexistent
files/dirs/collections returned 404, but it was in fact exercising a
code path that returned 404 because the token was not accepted.

Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

services/keep-web/handler.go
services/keep-web/handler_test.go
services/keep-web/server_test.go

index c16f01a1c4facf4426256c8c229b0a479f35ea17..c40b691006198eeaa7764baeb0fa0e25d2fd5500 100644 (file)
@@ -40,7 +40,7 @@ type handler struct {
 var urlPDHDecoder = strings.NewReplacer(" ", "+", "-", "+")
 
 var notFoundMessage = "Not Found"
-var unauthorizedMessage = "401 Unauthorized\r\n\r\nA valid Arvados token must be provided to access this resource.\r\n"
+var unauthorizedMessage = "401 Unauthorized\n\nA valid Arvados token must be provided to access this resource."
 
 // parseCollectionIDFromURL returns a UUID or PDH if s is a UUID or a
 // PDH (even if it is a PDH with "+" replaced by " " or "-");
@@ -352,15 +352,6 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                }
        }
 
-       if tokens == nil {
-               if !credentialsOK {
-                       http.Error(w, fmt.Sprintf("Authorization tokens are not accepted here: %v, and no anonymous user token is configured.", reasonNotAcceptingCredentials), http.StatusUnauthorized)
-               } else {
-                       http.Error(w, fmt.Sprintf("No authorization token in request, and no anonymous user token is configured."), http.StatusUnauthorized)
-               }
-               return
-       }
-
        if len(targetPath) > 0 && targetPath[0] == "_" {
                // If a collection has a directory called "t=foo" or
                // "_", it can be served at
@@ -377,7 +368,8 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                dirOpenMode = os.O_RDWR
        }
 
-       validToken := make(map[string]bool)
+       var tokenValid bool
+       var tokenScopeProblem bool
        var token string
        var tokenUser *arvados.User
        var sessionFS arvados.CustomFileSystem
@@ -393,14 +385,18 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                        http.Error(w, "cache error: "+err.Error(), http.StatusInternalServerError)
                        return
                }
+               if token != h.Cluster.Users.AnonymousUserToken {
+                       tokenValid = true
+               }
                f, err := fs.OpenFile(fsprefix, dirOpenMode, 0)
-               if errors.As(err, &statusErr) && statusErr.HTTPStatus() == http.StatusForbidden {
-                       // collection id is outside token scope
-                       validToken[token] = true
+               if errors.As(err, &statusErr) &&
+                       statusErr.HTTPStatus() == http.StatusForbidden &&
+                       token != h.Cluster.Users.AnonymousUserToken {
+                       // collection id is outside scope of supplied
+                       // token
+                       tokenScopeProblem = true
                        continue
-               }
-               validToken[token] = true
-               if os.IsNotExist(err) {
+               } else if os.IsNotExist(err) {
                        // collection does not exist or is not
                        // readable using this token
                        continue
@@ -425,22 +421,27 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                }
        }
        if session == nil {
-               if pathToken || !credentialsOK {
-                       // Either the URL is a "secret sharing link"
-                       // that didn't work out (and asking the client
-                       // for additional credentials would just be
-                       // confusing), or we don't even accept
-                       // credentials at this path.
+               if pathToken {
+                       // The URL is a "secret sharing link" that
+                       // didn't work out.  Asking the client for
+                       // additional credentials would just be
+                       // confusing.
                        http.Error(w, notFoundMessage, http.StatusNotFound)
                        return
                }
-               for _, t := range reqTokens {
-                       if validToken[t] {
-                               // The client provided valid token(s),
-                               // but the collection was not found.
-                               http.Error(w, notFoundMessage, http.StatusNotFound)
-                               return
-                       }
+               if tokenValid {
+                       // The client provided valid token(s), but the
+                       // collection was not found.
+                       http.Error(w, notFoundMessage, http.StatusNotFound)
+                       return
+               }
+               if tokenScopeProblem {
+                       // The client provided a valid token but
+                       // fetching a collection returned 401, which
+                       // means the token scope doesn't permit
+                       // fetching that collection.
+                       http.Error(w, notFoundMessage, http.StatusForbidden)
+                       return
                }
                // The client's token was invalid (e.g., expired), or
                // the client didn't even provide one.  Redirect to
@@ -477,10 +478,18 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                        target.RawQuery = redirkey + "=" + callback
                        w.Header().Add("Location", target.String())
                        w.WriteHeader(http.StatusSeeOther)
-               } else {
-                       w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
-                       http.Error(w, unauthorizedMessage, http.StatusUnauthorized)
+                       return
+               }
+               if !credentialsOK {
+                       http.Error(w, fmt.Sprintf("Authorization tokens are not accepted here: %v, and no anonymous user token is configured.", reasonNotAcceptingCredentials), http.StatusUnauthorized)
+                       return
                }
+               // If none of the above cases apply, suggest the
+               // user-agent (which is either a non-browser agent
+               // like wget, or a browser that can't redirect through
+               // a login flow) prompt the user for credentials.
+               w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
+               http.Error(w, unauthorizedMessage, http.StatusUnauthorized)
                return
        }
 
index d368b4447f355c8dd51e7e03adec68900d85df61..fac75214d68141c3ea2d75d303441619c002e14e 100644 (file)
@@ -443,7 +443,7 @@ func (s *IntegrationSuite) TestCollectionSharingToken(c *check.C) {
                nil,
                "",
                http.StatusNotFound,
-               notFoundMessage+"\n",
+               regexp.QuoteMeta(notFoundMessage+"\n"),
        )
 }
 
@@ -456,7 +456,7 @@ func (s *IntegrationSuite) TestSingleOriginSecretLinkBadToken(c *check.C) {
                nil,
                "",
                http.StatusNotFound,
-               notFoundMessage+"\n",
+               regexp.QuoteMeta(notFoundMessage+"\n"),
        )
 }
 
@@ -519,7 +519,7 @@ func (s *IntegrationSuite) TestVhostRedirectQueryTokenToBogusCookie(c *check.C)
                http.Header{"Sec-Fetch-Mode": {"cors"}},
                "",
                http.StatusUnauthorized,
-               unauthorizedMessage+"\n",
+               regexp.QuoteMeta(unauthorizedMessage+"\n"),
        )
        s.testVhostRedirectTokenToCookie(c, "GET",
                s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host+"/c="+arvadostest.FooCollection+"/foo",
@@ -527,7 +527,7 @@ func (s *IntegrationSuite) TestVhostRedirectQueryTokenToBogusCookie(c *check.C)
                nil,
                "",
                http.StatusUnauthorized,
-               unauthorizedMessage+"\n",
+               regexp.QuoteMeta(unauthorizedMessage+"\n"),
        )
 }
 
@@ -558,7 +558,7 @@ func (s *IntegrationSuite) TestVhostRedirectQueryTokenSingleOriginError(c *check
                nil,
                "",
                http.StatusBadRequest,
-               "cannot serve inline content at this URL (possible configuration error; see https://doc.arvados.org/install/install-keep-web.html#dns)\n",
+               regexp.QuoteMeta("cannot serve inline content at this URL (possible configuration error; see https://doc.arvados.org/install/install-keep-web.html#dns)\n"),
        )
 }
 
@@ -633,7 +633,7 @@ func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *chec
                nil,
                "",
                http.StatusBadRequest,
-               "cannot serve inline content at this URL (possible configuration error; see https://doc.arvados.org/install/install-keep-web.html#dns)\n",
+               regexp.QuoteMeta("cannot serve inline content at this URL (possible configuration error; see https://doc.arvados.org/install/install-keep-web.html#dns)\n"),
        )
 
        resp := s.testVhostRedirectTokenToCookie(c, "GET",
@@ -665,7 +665,7 @@ func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C)
                http.Header{"Content-Type": {"application/x-www-form-urlencoded"}},
                url.Values{"api_token": {arvadostest.SpectatorToken}}.Encode(),
                http.StatusNotFound,
-               notFoundMessage+"\n",
+               regexp.QuoteMeta(notFoundMessage+"\n"),
        )
 }
 
@@ -688,8 +688,8 @@ func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
                "",
                nil,
                "",
-               http.StatusNotFound,
-               notFoundMessage+"\n",
+               http.StatusUnauthorized,
+               "Authorization tokens are not accepted here: .*\n",
        )
 }
 
@@ -759,7 +759,7 @@ func (s *IntegrationSuite) TestForwardSlashSubstitution(c *check.C) {
 
        base := "http://download.example.com/by_id/" + coll.OwnerUUID + "/"
        for tryURL, expectRegexp := range map[string]string{
-               base: `(?ms).*href="./` + nameShownEscaped + `/"\S+` + nameShown + `.*`,
+               base:                          `(?ms).*href="./` + nameShownEscaped + `/"\S+` + nameShown + `.*`,
                base + nameShownEscaped + "/": `(?ms).*href="./filename"\S+filename.*`,
        } {
                u, _ := url.Parse(tryURL)
@@ -826,7 +826,7 @@ func (s *IntegrationSuite) TestXHRNoRedirect(c *check.C) {
        c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
 }
 
-func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString string, reqHeader http.Header, reqBody string, expectStatus int, expectRespBody string) *httptest.ResponseRecorder {
+func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString string, reqHeader http.Header, reqBody string, expectStatus int, matchRespBody string) *httptest.ResponseRecorder {
        if reqHeader == nil {
                reqHeader = http.Header{}
        }
@@ -844,7 +844,7 @@ func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, ho
        resp := httptest.NewRecorder()
        defer func() {
                c.Check(resp.Code, check.Equals, expectStatus)
-               c.Check(resp.Body.String(), check.Equals, expectRespBody)
+               c.Check(resp.Body.String(), check.Matches, matchRespBody)
        }()
 
        s.handler.ServeHTTP(resp, req)
@@ -1058,11 +1058,7 @@ func (s *IntegrationSuite) testDirectoryListing(c *check.C) {
                        c.Check(req.URL.Path, check.Equals, trial.redirect, comment)
                }
                if trial.expect == nil {
-                       if s.handler.Cluster.Users.AnonymousUserToken == "" {
-                               c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
-                       } else {
-                               c.Check(resp.Code, check.Equals, http.StatusNotFound, comment)
-                       }
+                       c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
                } else {
                        c.Check(resp.Code, check.Equals, http.StatusOK, comment)
                        for _, e := range trial.expect {
@@ -1083,11 +1079,7 @@ func (s *IntegrationSuite) testDirectoryListing(c *check.C) {
                resp = httptest.NewRecorder()
                s.handler.ServeHTTP(resp, req)
                if trial.expect == nil {
-                       if s.handler.Cluster.Users.AnonymousUserToken == "" {
-                               c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
-                       } else {
-                               c.Check(resp.Code, check.Equals, http.StatusNotFound, comment)
-                       }
+                       c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
                } else {
                        c.Check(resp.Code, check.Equals, http.StatusOK, comment)
                }
@@ -1103,11 +1095,7 @@ func (s *IntegrationSuite) testDirectoryListing(c *check.C) {
                resp = httptest.NewRecorder()
                s.handler.ServeHTTP(resp, req)
                if trial.expect == nil {
-                       if s.handler.Cluster.Users.AnonymousUserToken == "" {
-                               c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
-                       } else {
-                               c.Check(resp.Code, check.Equals, http.StatusNotFound, comment)
-                       }
+                       c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
                } else {
                        c.Check(resp.Code, check.Equals, http.StatusMultiStatus, comment)
                        for _, e := range trial.expect {
index 2aa11045503c021d397861bf7852f001f32843fe..b3d0b9b418533110ce550da62946c1651af2245b 100644 (file)
@@ -48,17 +48,17 @@ func (s *IntegrationSuite) TestNoToken(c *check.C) {
                "",
                "bogustoken",
        } {
-               hdr, body, _ := s.runCurl(c, token, "collections.example.com", "/collections/"+arvadostest.FooCollection+"/foo")
-               c.Check(hdr, check.Matches, `(?s)HTTP/1.1 404 Not Found\r\n.*`)
-               c.Check(strings.TrimSpace(body), check.Equals, notFoundMessage)
+               hdr, body, _ := s.runCurl(c, token, s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host, "/c="+arvadostest.FooCollection+"/foo")
+               c.Check(hdr, check.Matches, `(?s)HTTP/1.1 401 Unauthorized\r\n.*`)
+               c.Check(strings.TrimSpace(body), check.Equals, unauthorizedMessage)
 
                if token != "" {
-                       hdr, body, _ = s.runCurl(c, token, "collections.example.com", "/collections/download/"+arvadostest.FooCollection+"/"+token+"/foo")
+                       hdr, body, _ = s.runCurl(c, token, s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host, "/collections/download/"+arvadostest.FooCollection+"/"+token+"/foo")
                        c.Check(hdr, check.Matches, `(?s)HTTP/1.1 404 Not Found\r\n.*`)
                        c.Check(strings.TrimSpace(body), check.Equals, notFoundMessage)
                }
 
-               hdr, body, _ = s.runCurl(c, token, "collections.example.com", "/bad-route")
+               hdr, body, _ = s.runCurl(c, token, s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host, "/bad-route")
                c.Check(hdr, check.Matches, `(?s)HTTP/1.1 404 Not Found\r\n.*`)
                c.Check(strings.TrimSpace(body), check.Equals, notFoundMessage)
        }
@@ -75,22 +75,18 @@ func (s *IntegrationSuite) Test404(c *check.C) {
                "/download",
                "/collections",
                "/collections/",
-               // Implicit/generated index is not implemented yet;
-               // until then, return 404.
-               "/collections/" + arvadostest.FooCollection,
-               "/collections/" + arvadostest.FooCollection + "/",
-               "/collections/" + arvadostest.FooBarDirCollection + "/dir1",
-               "/collections/" + arvadostest.FooBarDirCollection + "/dir1/",
-               // Non-existent file in collection
-               "/collections/" + arvadostest.FooCollection + "/theperthcountyconspiracy",
+               // Non-existent file/directory
+               "/c=" + arvadostest.FooCollection + "/theperthcountyconspiracy",
+               "/c=" + arvadostest.FooCollection + "/theperthcountyconspiracy/",
                "/collections/download/" + arvadostest.FooCollection + "/" + arvadostest.ActiveToken + "/theperthcountyconspiracy",
+               "/collections/download/" + arvadostest.FooCollection + "/" + arvadostest.ActiveToken + "/theperthcountyconspiracy/",
                // Non-existent collection
-               "/collections/" + arvadostest.NonexistentCollection,
-               "/collections/" + arvadostest.NonexistentCollection + "/",
-               "/collections/" + arvadostest.NonexistentCollection + "/theperthcountyconspiracy",
+               "/c=" + arvadostest.NonexistentCollection,
+               "/c=" + arvadostest.NonexistentCollection + "/",
+               "/c=" + arvadostest.NonexistentCollection + "/theperthcountyconspiracy",
                "/collections/download/" + arvadostest.NonexistentCollection + "/" + arvadostest.ActiveToken + "/theperthcountyconspiracy",
        } {
-               hdr, body, _ := s.runCurl(c, arvadostest.ActiveToken, "collections.example.com", uri)
+               hdr, body, _ := s.runCurl(c, arvadostest.ActiveToken, s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host, uri)
                c.Check(hdr, check.Matches, "(?s)HTTP/1.1 404 Not Found\r\n.*")
                if len(body) > 0 {
                        c.Check(strings.TrimSpace(body), check.Equals, notFoundMessage)
@@ -264,10 +260,14 @@ func (s *IntegrationSuite) Test200(c *check.C) {
 }
 
 // Return header block and body.
-func (s *IntegrationSuite) runCurl(c *check.C, auth, host, uri string, args ...string) (hdr, bodyPart string, bodySize int64) {
+func (s *IntegrationSuite) runCurl(c *check.C, auth, hostport, uri string, args ...string) (hdr, bodyPart string, bodySize int64) {
        curlArgs := []string{"--silent", "--show-error", "--include"}
        testHost, testPort, _ := net.SplitHostPort(s.testServer.URL[7:])
-       curlArgs = append(curlArgs, "--resolve", host+":"+testPort+":"+testHost)
+       host, port, _ := net.SplitHostPort(hostport)
+       if port == "" {
+               port = "80"
+       }
+       curlArgs = append(curlArgs, "--connect-to", host+":"+port+":"+testHost+":"+testPort)
        if strings.Contains(auth, " ") {
                // caller supplied entire Authorization header value
                curlArgs = append(curlArgs, "-H", "Authorization: "+auth)
@@ -276,7 +276,7 @@ func (s *IntegrationSuite) runCurl(c *check.C, auth, host, uri string, args ...s
                curlArgs = append(curlArgs, "-H", "Authorization: Bearer "+auth)
        }
        curlArgs = append(curlArgs, args...)
-       curlArgs = append(curlArgs, "http://"+host+":"+testPort+uri)
+       curlArgs = append(curlArgs, "http://"+hostport+uri)
        c.Log(fmt.Sprintf("curlArgs == %#v", curlArgs))
        cmd := exec.Command("curl", curlArgs...)
        stdout, err := cmd.StdoutPipe()