5824: Move spying ResponseWriter to SDK.
[arvados.git] / services / arv-git-httpd / auth_handler.go
1 package main
2
3 import (
4         "log"
5         "net/http"
6         "net/http/cgi"
7         "os"
8         "strings"
9         "sync"
10         "time"
11
12         "git.curoverse.com/arvados.git/sdk/go/auth"
13         "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
14         "git.curoverse.com/arvados.git/sdk/go/httpserver"
15 )
16
17 func newArvadosClient() interface{} {
18         arv, err := arvadosclient.MakeArvadosClient()
19         if err != nil {
20                 log.Println("MakeArvadosClient:", err)
21                 return nil
22         }
23         return &arv
24 }
25
26 var connectionPool = &sync.Pool{New: newArvadosClient}
27
28 type authHandler struct {
29         handler *cgi.Handler
30 }
31
32 func (h *authHandler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
33         var statusCode int
34         var statusText string
35         var apiToken string
36         var repoName string
37         var validApiToken bool
38
39         w := httpserver.WrapResponseWriter(wOrig)
40
41         defer func() {
42                 if w.WroteStatus() == 0 {
43                         // Nobody has called WriteHeader yet: that
44                         // must be our job.
45                         w.WriteHeader(statusCode)
46                         w.Write([]byte(statusText))
47                 }
48
49                 // If the given password is a valid token, log the first 10 characters of the token.
50                 // Otherwise: log the string <invalid> if a password is given, else an empty string.
51                 passwordToLog := ""
52                 if !validApiToken {
53                         if len(apiToken) > 0 {
54                                 passwordToLog = "<invalid>"
55                         }
56                 } else {
57                         passwordToLog = apiToken[0:10]
58                 }
59
60                 log.Println(quoteStrings(r.RemoteAddr, passwordToLog, w.WroteStatus(), statusText, repoName, r.Method, r.URL.Path)...)
61         }()
62
63         creds := auth.NewCredentialsFromHTTPRequest(r)
64         if len(creds.Tokens) == 0 {
65                 statusCode, statusText = http.StatusUnauthorized, "no credentials provided"
66                 w.Header().Add("WWW-Authenticate", "Basic realm=\"git\"")
67                 return
68         }
69         apiToken = creds.Tokens[0]
70
71         // Access to paths "/foo/bar.git/*" and "/foo/bar/.git/*" are
72         // protected by the permissions on the repository named
73         // "foo/bar".
74         pathParts := strings.SplitN(r.URL.Path[1:], ".git/", 2)
75         if len(pathParts) != 2 {
76                 statusCode, statusText = http.StatusBadRequest, "bad request"
77                 return
78         }
79         repoName = pathParts[0]
80         repoName = strings.TrimRight(repoName, "/")
81
82         arv, ok := connectionPool.Get().(*arvadosclient.ArvadosClient)
83         if !ok || arv == nil {
84                 statusCode, statusText = http.StatusInternalServerError, "connection pool failed"
85                 return
86         }
87         defer connectionPool.Put(arv)
88
89         // Ask API server whether the repository is readable using
90         // this token (by trying to read it!)
91         arv.ApiToken = apiToken
92         reposFound := arvadosclient.Dict{}
93         if err := arv.List("repositories", arvadosclient.Dict{
94                 "filters": [][]string{{"name", "=", repoName}},
95         }, &reposFound); err != nil {
96                 statusCode, statusText = http.StatusInternalServerError, err.Error()
97                 return
98         }
99         validApiToken = true
100         if avail, ok := reposFound["items_available"].(float64); !ok {
101                 statusCode, statusText = http.StatusInternalServerError, "bad list response from API"
102                 return
103         } else if avail < 1 {
104                 statusCode, statusText = http.StatusNotFound, "not found"
105                 return
106         } else if avail > 1 {
107                 statusCode, statusText = http.StatusInternalServerError, "name collision"
108                 return
109         }
110
111         repoUUID := reposFound["items"].([]interface{})[0].(map[string]interface{})["uuid"].(string)
112
113         isWrite := strings.HasSuffix(r.URL.Path, "/git-receive-pack")
114         if !isWrite {
115                 statusText = "read"
116         } else {
117                 err := arv.Update("repositories", repoUUID, arvadosclient.Dict{
118                         "repository": arvadosclient.Dict{
119                                 "modified_at": time.Now().String(),
120                         },
121                 }, &arvadosclient.Dict{})
122                 if err != nil {
123                         statusCode, statusText = http.StatusForbidden, err.Error()
124                         return
125                 }
126                 statusText = "write"
127         }
128
129         // Regardless of whether the client asked for "/foo.git" or
130         // "/foo/.git", we choose whichever variant exists in our repo
131         // root, and we try {uuid}.git and {uuid}/.git first. If none
132         // of these exist, we 404 even though the API told us the repo
133         // _should_ exist (presumably this means the repo was just
134         // created, and gitolite sync hasn't run yet).
135         rewrittenPath := ""
136         tryDirs := []string{
137                 "/" + repoUUID + ".git",
138                 "/" + repoUUID + "/.git",
139                 "/" + repoName + ".git",
140                 "/" + repoName + "/.git",
141         }
142         for _, dir := range tryDirs {
143                 if fileInfo, err := os.Stat(theConfig.Root + dir); err != nil {
144                         if !os.IsNotExist(err) {
145                                 statusCode, statusText = http.StatusInternalServerError, err.Error()
146                                 return
147                         }
148                 } else if fileInfo.IsDir() {
149                         rewrittenPath = dir + "/" + pathParts[1]
150                         break
151                 }
152         }
153         if rewrittenPath == "" {
154                 log.Println("WARNING:", repoUUID,
155                         "git directory not found in", theConfig.Root, tryDirs)
156                 // We say "content not found" to disambiguate from the
157                 // earlier "API says that repo does not exist" error.
158                 statusCode, statusText = http.StatusNotFound, "content not found"
159                 return
160         }
161         r.URL.Path = rewrittenPath
162
163         handlerCopy := *h.handler
164         handlerCopy.Env = append(handlerCopy.Env, "REMOTE_USER="+r.RemoteAddr) // Should be username
165         handlerCopy.ServeHTTP(&w, r)
166 }
167
168 var escaper = strings.NewReplacer("\"", "\\\"", "\\", "\\\\", "\n", "\\n")
169
170 // Transform strings so they are safer to write in logs (e.g.,
171 // 'foo"bar' becomes '"foo\"bar"'). Non-string args are left alone.
172 func quoteStrings(args ...interface{}) []interface{} {
173         for i, arg := range args {
174                 if s, ok := arg.(string); ok {
175                         args[i] = "\"" + escaper.Replace(s) + "\""
176                 }
177         }
178         return args
179 }