Merge branch '18947-githttpd'
[arvados.git] / services / githttpd / auth_handler.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package githttpd
6
7 import (
8         "errors"
9         "log"
10         "net/http"
11         "os"
12         "regexp"
13         "strings"
14         "time"
15
16         "git.arvados.org/arvados.git/sdk/go/arvados"
17         "git.arvados.org/arvados.git/sdk/go/arvadosclient"
18         "git.arvados.org/arvados.git/sdk/go/auth"
19         "git.arvados.org/arvados.git/sdk/go/httpserver"
20         "github.com/sirupsen/logrus"
21 )
22
23 type authHandler struct {
24         handler    http.Handler
25         clientPool *arvadosclient.ClientPool
26         cluster    *arvados.Cluster
27 }
28
29 func (h *authHandler) CheckHealth() error {
30         return nil
31 }
32
33 func (h *authHandler) Done() <-chan struct{} {
34         return nil
35 }
36
37 func (h *authHandler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
38         var statusCode int
39         var statusText string
40         var apiToken string
41
42         w := httpserver.WrapResponseWriter(wOrig)
43
44         if r.Method == "OPTIONS" {
45                 method := r.Header.Get("Access-Control-Request-Method")
46                 if method != "GET" && method != "POST" {
47                         w.WriteHeader(http.StatusMethodNotAllowed)
48                         return
49                 }
50                 w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
51                 w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
52                 w.Header().Set("Access-Control-Allow-Origin", "*")
53                 w.Header().Set("Access-Control-Max-Age", "86400")
54                 w.WriteHeader(http.StatusOK)
55                 return
56         }
57
58         if r.Header.Get("Origin") != "" {
59                 // Allow simple cross-origin requests without user
60                 // credentials ("user credentials" as defined by CORS,
61                 // i.e., cookies, HTTP authentication, and client-side
62                 // SSL certificates. See
63                 // http://www.w3.org/TR/cors/#user-credentials).
64                 w.Header().Set("Access-Control-Allow-Origin", "*")
65         }
66
67         defer func() {
68                 if w.WroteStatus() == 0 {
69                         // Nobody has called WriteHeader yet: that
70                         // must be our job.
71                         w.WriteHeader(statusCode)
72                         if statusCode >= 400 {
73                                 w.Write([]byte(statusText))
74                         }
75                 }
76         }()
77
78         creds := auth.CredentialsFromRequest(r)
79         if len(creds.Tokens) == 0 {
80                 statusCode, statusText = http.StatusUnauthorized, "no credentials provided"
81                 w.Header().Add("WWW-Authenticate", "Basic realm=\"git\"")
82                 return
83         }
84         apiToken = creds.Tokens[0]
85
86         // Access to paths "/foo/bar.git/*" and "/foo/bar/.git/*" are
87         // protected by the permissions on the repository named
88         // "foo/bar".
89         pathParts := strings.SplitN(r.URL.Path[1:], ".git/", 2)
90         if len(pathParts) != 2 {
91                 statusCode, statusText = http.StatusNotFound, "not found"
92                 return
93         }
94         repoName := pathParts[0]
95         repoName = strings.TrimRight(repoName, "/")
96         httpserver.SetResponseLogFields(r.Context(), logrus.Fields{
97                 "repoName": repoName,
98         })
99
100         arv := h.clientPool.Get()
101         if arv == nil {
102                 statusCode, statusText = http.StatusInternalServerError, "connection pool failed: "+h.clientPool.Err().Error()
103                 return
104         }
105         defer h.clientPool.Put(arv)
106
107         // Log the UUID if the supplied token is a v2 token, otherwise
108         // just the last five characters.
109         httpserver.SetResponseLogFields(r.Context(), logrus.Fields{
110                 "tokenUUID": func() string {
111                         if strings.HasPrefix(apiToken, "v2/") && strings.IndexRune(apiToken[3:], '/') == 27 {
112                                 // UUID part of v2 token
113                                 return apiToken[3:30]
114                         } else if len(apiToken) > 5 {
115                                 return "[...]" + apiToken[len(apiToken)-5:]
116                         } else {
117                                 return apiToken
118                         }
119                 }(),
120         })
121
122         // Ask API server whether the repository is readable using
123         // this token (by trying to read it!)
124         arv.ApiToken = apiToken
125         repoUUID, err := h.lookupRepo(arv, repoName)
126         if err != nil {
127                 statusCode, statusText = http.StatusInternalServerError, err.Error()
128                 return
129         }
130         if repoUUID == "" {
131                 statusCode, statusText = http.StatusNotFound, "not found"
132                 return
133         }
134
135         isWrite := strings.HasSuffix(r.URL.Path, "/git-receive-pack")
136         if !isWrite {
137                 statusText = "read"
138         } else {
139                 err := arv.Update("repositories", repoUUID, arvadosclient.Dict{
140                         "repository": arvadosclient.Dict{
141                                 "modified_at": time.Now().String(),
142                         },
143                 }, &arvadosclient.Dict{})
144                 if err != nil {
145                         statusCode, statusText = http.StatusForbidden, err.Error()
146                         return
147                 }
148                 statusText = "write"
149         }
150
151         // Regardless of whether the client asked for "/foo.git" or
152         // "/foo/.git", we choose whichever variant exists in our repo
153         // root, and we try {uuid}.git and {uuid}/.git first. If none
154         // of these exist, we 404 even though the API told us the repo
155         // _should_ exist (presumably this means the repo was just
156         // created, and gitolite sync hasn't run yet).
157         rewrittenPath := ""
158         tryDirs := []string{
159                 "/" + repoUUID + ".git",
160                 "/" + repoUUID + "/.git",
161                 "/" + repoName + ".git",
162                 "/" + repoName + "/.git",
163         }
164         for _, dir := range tryDirs {
165                 if fileInfo, err := os.Stat(h.cluster.Git.Repositories + dir); err != nil {
166                         if !os.IsNotExist(err) {
167                                 statusCode, statusText = http.StatusInternalServerError, err.Error()
168                                 return
169                         }
170                 } else if fileInfo.IsDir() {
171                         rewrittenPath = dir + "/" + pathParts[1]
172                         break
173                 }
174         }
175         if rewrittenPath == "" {
176                 log.Println("WARNING:", repoUUID,
177                         "git directory not found in", h.cluster.Git.Repositories, tryDirs)
178                 // We say "content not found" to disambiguate from the
179                 // earlier "API says that repo does not exist" error.
180                 statusCode, statusText = http.StatusNotFound, "content not found"
181                 return
182         }
183         r.URL.Path = rewrittenPath
184
185         h.handler.ServeHTTP(w, r)
186 }
187
188 var uuidRegexp = regexp.MustCompile(`^[0-9a-z]{5}-s0uqq-[0-9a-z]{15}$`)
189
190 func (h *authHandler) lookupRepo(arv *arvadosclient.ArvadosClient, repoName string) (string, error) {
191         reposFound := arvadosclient.Dict{}
192         var column string
193         if uuidRegexp.MatchString(repoName) {
194                 column = "uuid"
195         } else {
196                 column = "name"
197         }
198         err := arv.List("repositories", arvadosclient.Dict{
199                 "filters": [][]string{{column, "=", repoName}},
200         }, &reposFound)
201         if err != nil {
202                 return "", err
203         } else if avail, ok := reposFound["items_available"].(float64); !ok {
204                 return "", errors.New("bad list response from API")
205         } else if avail < 1 {
206                 return "", nil
207         } else if avail > 1 {
208                 return "", errors.New("name collision")
209         }
210         return reposFound["items"].([]interface{})[0].(map[string]interface{})["uuid"].(string), nil
211 }