X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/452dace2a30db53753d98baa21905b32aac4b78d..eb1c9afa9a92c1506e5d4d1161b6e74d919e8f00:/services/arv-git-httpd/auth_handler.go diff --git a/services/arv-git-httpd/auth_handler.go b/services/arv-git-httpd/auth_handler.go index f91ed3f8c0..b4dc58b24f 100644 --- a/services/arv-git-httpd/auth_handler.go +++ b/services/arv-git-httpd/auth_handler.go @@ -1,91 +1,167 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + package main import ( + "errors" "log" "net/http" - "net/http/cgi" "os" + "regexp" "strings" "sync" "time" "git.curoverse.com/arvados.git/sdk/go/arvadosclient" + "git.curoverse.com/arvados.git/sdk/go/auth" + "git.curoverse.com/arvados.git/sdk/go/httpserver" ) -func newArvadosClient() interface{} { - arv, err := arvadosclient.MakeArvadosClient() - if err != nil { - log.Println("MakeArvadosClient:", err) - return nil - } - return &arv -} - -var connectionPool = &sync.Pool{New: newArvadosClient} - -type spyingResponseWriter struct { - http.ResponseWriter - wroteStatus *int -} - -func (w spyingResponseWriter) WriteHeader(s int) { - *w.wroteStatus = s - w.ResponseWriter.WriteHeader(s) +type authHandler struct { + handler http.Handler + clientPool *arvadosclient.ClientPool + setupOnce sync.Once } -type authHandler struct { - handler *cgi.Handler +func (h *authHandler) setup() { + ac, err := arvadosclient.New(&theConfig.Client) + if err != nil { + log.Fatal(err) + } + h.clientPool = &arvadosclient.ClientPool{Prototype: ac} } func (h *authHandler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { + h.setupOnce.Do(h.setup) + var statusCode int var statusText string - var username, password string + var apiToken string var repoName string - var wroteStatus int + var validApiToken bool + + w := httpserver.WrapResponseWriter(wOrig) + + if r.Method == "OPTIONS" { + method := r.Header.Get("Access-Control-Request-Method") + if method != "GET" && method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Max-Age", "86400") + w.WriteHeader(http.StatusOK) + return + } - w := spyingResponseWriter{wOrig, &wroteStatus} + if r.Header.Get("Origin") != "" { + // Allow simple cross-origin requests without user + // credentials ("user credentials" as defined by CORS, + // i.e., cookies, HTTP authentication, and client-side + // SSL certificates. See + // http://www.w3.org/TR/cors/#user-credentials). + w.Header().Set("Access-Control-Allow-Origin", "*") + } defer func() { - if wroteStatus == 0 { - // Nobody has called WriteHeader yet: that must be our job. + if w.WroteStatus() == 0 { + // Nobody has called WriteHeader yet: that + // must be our job. w.WriteHeader(statusCode) - w.Write([]byte(statusText)) + if statusCode >= 400 { + w.Write([]byte(statusText)) + } } - log.Println(quoteStrings(r.RemoteAddr, username, password, wroteStatus, statusText, repoName, r.URL.Path)...) + + // If the given password is a valid token, log the first 10 characters of the token. + // Otherwise: log the string if a password is given, else an empty string. + passwordToLog := "" + if !validApiToken { + if len(apiToken) > 0 { + passwordToLog = "" + } + } else { + passwordToLog = apiToken[0:10] + } + + httpserver.Log(r.RemoteAddr, passwordToLog, w.WroteStatus(), statusText, repoName, r.Method, r.URL.Path) }() - // HTTP request username is logged, but unused. Password is an - // Arvados API token. - username, password, ok := BasicAuth(r) - if !ok || username == "" || password == "" { + creds := auth.NewCredentialsFromHTTPRequest(r) + if len(creds.Tokens) == 0 { statusCode, statusText = http.StatusUnauthorized, "no credentials provided" - w.Header().Add("WWW-Authenticate", "basic") + w.Header().Add("WWW-Authenticate", "Basic realm=\"git\"") return } + apiToken = creds.Tokens[0] // Access to paths "/foo/bar.git/*" and "/foo/bar/.git/*" are // protected by the permissions on the repository named // "foo/bar". pathParts := strings.SplitN(r.URL.Path[1:], ".git/", 2) if len(pathParts) != 2 { - statusCode, statusText = http.StatusBadRequest, "bad request" + statusCode, statusText = http.StatusNotFound, "not found" return } repoName = pathParts[0] repoName = strings.TrimRight(repoName, "/") + arv := h.clientPool.Get() + if arv == nil { + statusCode, statusText = http.StatusInternalServerError, "connection pool failed: "+h.clientPool.Err().Error() + return + } + defer h.clientPool.Put(arv) + + // Ask API server whether the repository is readable using + // this token (by trying to read it!) + arv.ApiToken = apiToken + repoUUID, err := h.lookupRepo(arv, repoName) + if err != nil { + statusCode, statusText = http.StatusInternalServerError, err.Error() + return + } + validApiToken = true + if repoUUID == "" { + statusCode, statusText = http.StatusNotFound, "not found" + return + } + + isWrite := strings.HasSuffix(r.URL.Path, "/git-receive-pack") + if !isWrite { + statusText = "read" + } else { + err := arv.Update("repositories", repoUUID, arvadosclient.Dict{ + "repository": arvadosclient.Dict{ + "modified_at": time.Now().String(), + }, + }, &arvadosclient.Dict{}) + if err != nil { + statusCode, statusText = http.StatusForbidden, err.Error() + return + } + statusText = "write" + } + // Regardless of whether the client asked for "/foo.git" or // "/foo/.git", we choose whichever variant exists in our repo - // root. If neither exists, we won't even bother checking - // authentication. + // root, and we try {uuid}.git and {uuid}/.git first. If none + // of these exist, we 404 even though the API told us the repo + // _should_ exist (presumably this means the repo was just + // created, and gitolite sync hasn't run yet). rewrittenPath := "" tryDirs := []string{ + "/" + repoUUID + ".git", + "/" + repoUUID + "/.git", "/" + repoName + ".git", "/" + repoName + "/.git", } for _, dir := range tryDirs { - if fileInfo, err := os.Stat(theConfig.Root + dir); err != nil { + if fileInfo, err := os.Stat(theConfig.RepoRoot + dir); err != nil { if !os.IsNotExist(err) { statusCode, statusText = http.StatusInternalServerError, err.Error() return @@ -96,67 +172,39 @@ func (h *authHandler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { } } if rewrittenPath == "" { - statusCode, statusText = http.StatusNotFound, "not found" + log.Println("WARNING:", repoUUID, + "git directory not found in", theConfig.RepoRoot, tryDirs) + // We say "content not found" to disambiguate from the + // earlier "API says that repo does not exist" error. + statusCode, statusText = http.StatusNotFound, "content not found" return } r.URL.Path = rewrittenPath - arv, ok := connectionPool.Get().(*arvadosclient.ArvadosClient) - if !ok || arv == nil { - statusCode, statusText = http.StatusInternalServerError, "connection pool failed" - return - } - defer connectionPool.Put(arv) + h.handler.ServeHTTP(w, r) +} - // Ask API server whether the repository is readable using this token (by trying to read it!) - arv.ApiToken = password +var uuidRegexp = regexp.MustCompile(`^[0-9a-z]{5}-s0uqq-[0-9a-z]{15}$`) + +func (h *authHandler) lookupRepo(arv *arvadosclient.ArvadosClient, repoName string) (string, error) { reposFound := arvadosclient.Dict{} - if err := arv.List("repositories", arvadosclient.Dict{ - "filters": [][]string{[]string{"name", "=", repoName}}, - }, &reposFound); err != nil { - statusCode, statusText = http.StatusInternalServerError, err.Error() - return + var column string + if uuidRegexp.MatchString(repoName) { + column = "uuid" + } else { + column = "name" } - if avail, ok := reposFound["items_available"].(float64); !ok { - statusCode, statusText = http.StatusInternalServerError, "bad list response from API" - return + err := arv.List("repositories", arvadosclient.Dict{ + "filters": [][]string{{column, "=", repoName}}, + }, &reposFound) + if err != nil { + return "", err + } else if avail, ok := reposFound["items_available"].(float64); !ok { + return "", errors.New("bad list response from API") } else if avail < 1 { - statusCode, statusText = http.StatusNotFound, "not found" - return + return "", nil } else if avail > 1 { - statusCode, statusText = http.StatusInternalServerError, "name collision" - return - } - isWrite := strings.HasSuffix(r.URL.Path, "/git-receive-pack") - if !isWrite { - statusText = "read" - } else { - uuid := reposFound["items"].([]interface{})[0].(map[string]interface{})["uuid"].(string) - err := arv.Update("repositories", uuid, arvadosclient.Dict{ - "repository": arvadosclient.Dict{ - "modified_at": time.Now().String(), - }, - }, &arvadosclient.Dict{}) - if err != nil { - statusCode, statusText = http.StatusForbidden, err.Error() - return - } - statusText = "write" - } - handlerCopy := *h.handler - handlerCopy.Env = append(handlerCopy.Env, "REMOTE_USER="+r.RemoteAddr) // Should be username - handlerCopy.ServeHTTP(&w, r) -} - -var escaper = strings.NewReplacer("\"", "\\\"", "\\", "\\\\", "\n", "\\n") - -// Transform strings so they are safer to write in logs (e.g., -// 'foo"bar' becomes '"foo\"bar"'). Non-string args are left alone. -func quoteStrings(args ...interface{}) []interface{} { - for i, arg := range args { - if s, ok := arg.(string); ok { - args[i] = "\"" + escaper.Replace(s) + "\"" - } + return "", errors.New("name collision") } - return args + return reposFound["items"].([]interface{})[0].(map[string]interface{})["uuid"].(string), nil }