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