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