aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorLibravatar Rutger Broekhoff2023-12-30 21:51:50 +0100
committerLibravatar Rutger Broekhoff2023-12-30 21:51:50 +0100
commit45939098e918041f0ce524fa125cf0a1cdfd49c0 (patch)
treea8846a610c5535dbbeaf73f17edb5286ad751903 /cmd
parentdc85c97f3730b066f3603081d21df67349f9a84d (diff)
downloadgitolfs3-45939098e918041f0ce524fa125cf0a1cdfd49c0.tar.gz
gitolfs3-45939098e918041f0ce524fa125cf0a1cdfd49c0.zip
Implement authorization in git-lfs-server, test presigned PUTs
Diffstat (limited to 'cmd')
-rw-r--r--cmd/git-lfs-authenticate/main.go7
-rw-r--r--cmd/git-lfs-server/main.go206
2 files changed, 182 insertions, 31 deletions
diff --git a/cmd/git-lfs-authenticate/main.go b/cmd/git-lfs-authenticate/main.go
index f48fe5c..027a2f9 100644
--- a/cmd/git-lfs-authenticate/main.go
+++ b/cmd/git-lfs-authenticate/main.go
@@ -100,8 +100,11 @@ type authenticateResponse struct {
100 Header map[string]string `json:"header"` 100 Header map[string]string `json:"header"`
101 // In seconds. 101 // In seconds.
102 ExpiresIn int64 `json:"expires_in,omitempty"` 102 ExpiresIn int64 `json:"expires_in,omitempty"`
103 // expires_at (RFC3339) could also be used, but we leave it out since we 103 // The expires_at (RFC3339) property could also be used, but we leave it
104 // don't use it. 104 // out since we don't use it. It is also possibleto specify the href
105 // property, making the Git LFS use this instead of the usual Service
106 // Discovery mechanism. See
107 // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/docs/api/server-discovery.md#ssh
105} 108}
106 109
107func wipe(b []byte) { 110func wipe(b []byte) {
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go
index c76c5d9..934f2ea 100644
--- a/cmd/git-lfs-server/main.go
+++ b/cmd/git-lfs-server/main.go
@@ -1,7 +1,10 @@
1package main 1package main
2 2
3import ( 3import (
4 "bytes"
4 "context" 5 "context"
6 "crypto/ed25519"
7 "encoding/hex"
5 "encoding/json" 8 "encoding/json"
6 "errors" 9 "errors"
7 "fmt" 10 "fmt"
@@ -14,10 +17,12 @@ import (
14 "path" 17 "path"
15 "regexp" 18 "regexp"
16 "slices" 19 "slices"
20 "strconv"
17 "strings" 21 "strings"
18 "time" 22 "time"
19 "unicode" 23 "unicode"
20 24
25 "github.com/golang-jwt/jwt/v5"
21 "github.com/minio/minio-go/v7" 26 "github.com/minio/minio-go/v7"
22 "github.com/minio/minio-go/v7/pkg/credentials" 27 "github.com/minio/minio-go/v7/pkg/credentials"
23 "github.com/rs/xid" 28 "github.com/rs/xid"
@@ -86,6 +91,7 @@ type handler struct {
86 bucket string 91 bucket string
87 anonUser string 92 anonUser string
88 gitolitePath string 93 gitolitePath string
94 publicKey ed25519.PublicKey
89} 95}
90 96
91// Requires lowercase hash 97// Requires lowercase hash
@@ -172,6 +178,36 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par
172 } 178 }
173} 179}
174 180
181func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject {
182 fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash)
183 expiresIn := time.Hour * 24
184
185 presigned, err := h.mc.Presign(ctx, http.MethodPut, h.bucket, fullPath, expiresIn, url.Values{
186 "x-amz-sdk-checksum-algorithm": {"sha256"},
187 "x-amz-checksum-sha256": {obj.fullHash},
188 "Content-Length": {strconv.FormatUint(obj.size, 10)},
189 })
190 if err != nil {
191 // TODO: consider not making this an object-specific, but rather a
192 // generic error such that the entire Batch API request fails.
193 reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err)
194 return makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError)
195 }
196
197 authenticated := true
198 return batchResponseObject{
199 OID: obj.fullHash,
200 Size: obj.size,
201 Authenticated: &authenticated,
202 Actions: map[operation]batchAction{
203 operationUpload: {
204 HRef: presigned.String(),
205 ExpiresIn: int64(expiresIn.Seconds()),
206 },
207 },
208 }
209}
210
175type parsedBatchObject struct { 211type parsedBatchObject struct {
176 firstByte string 212 firstByte string
177 secondByte string 213 secondByte string
@@ -196,6 +232,103 @@ type requestID struct{}
196 232
197var requestIDKey requestID 233var requestIDKey requestID
198 234
235// TODO: make a shared package for this
236type gitolfs3Claims struct {
237 Repository string `json:"repository"`
238 Permission operation `json:"permission"`
239}
240
241type customClaims struct {
242 Gitolfs3 gitolfs3Claims `json:"gitolfs3"`
243 *jwt.RegisteredClaims
244}
245
246// Request to perform <operation> in <repository> [on reference <refspec>]
247type operationRequest struct {
248 operation operation
249 repository string
250 refspec *string
251}
252
253func getGitoliteAccess(repo, user, gitolitePerm string, refspec *string) (bool, error) {
254 // gitolite access -q: returns only exit code
255 gitoliteArgs := []string{"access", "-q", repo, user, gitolitePerm}
256 if refspec != nil {
257 gitoliteArgs = append(gitoliteArgs, *refspec)
258 }
259 cmd := exec.Command("gitolite", gitoliteArgs...)
260 err := cmd.Run()
261 if err != nil {
262 var exitErr *exec.ExitError
263 if !errors.As(err, &exitErr) {
264 return false, fmt.Errorf("(running %s): %w", cmd, err)
265 }
266 return false, nil
267 }
268 return true, nil
269}
270
271func (h *handler) authorize(ctx context.Context, w http.ResponseWriter, r *http.Request, or operationRequest) bool {
272 user := h.anonUser
273
274 if authz := r.Header.Get("Authorization"); authz != "" {
275 if !strings.HasPrefix(authz, "Bearer ") {
276 makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest)
277 return false
278 }
279 authz = strings.TrimPrefix(authz, "Bearer ")
280
281 var claims customClaims
282 _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) {
283 if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
284 return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"])
285 }
286 return h.publicKey, nil
287 })
288 if err != nil {
289 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
290 return false
291 }
292
293 if claims.Gitolfs3.Repository != or.repository {
294 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
295 return false
296 }
297 if claims.Gitolfs3.Permission == operationDownload && or.operation == operationUpload {
298 makeRespError(ctx, w, "Forbidden", http.StatusForbidden)
299 return false
300 }
301
302 user = claims.Subject
303 }
304
305 readAccess, err := getGitoliteAccess(or.repository, user, "R", or.refspec)
306 if err != nil {
307 reqlog(ctx, "Error checking access info: %s", err)
308 makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError)
309 return false
310 }
311 if !readAccess {
312 makeRespError(ctx, w, "Repository not found", http.StatusNotFound)
313 return false
314 }
315 if or.operation == operationUpload {
316 writeAccess, err := getGitoliteAccess(or.repository, user, "W", or.refspec)
317 if err != nil {
318 reqlog(ctx, "Error checking access info: %s", err)
319 makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError)
320 return false
321 }
322 // User has read access but no write access
323 if !writeAccess {
324 makeRespError(ctx, w, "Forbidden", http.StatusForbidden)
325 return false
326 }
327 }
328
329 return true
330}
331
199func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 332func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
200 ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String()) 333 ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String())
201 334
@@ -229,42 +362,29 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
229 makeRespError(ctx, w, "Failed to parse request body as JSON", http.StatusBadRequest) 362 makeRespError(ctx, w, "Failed to parse request body as JSON", http.StatusBadRequest)
230 return 363 return
231 } 364 }
232 365 if body.Operation != operationDownload && body.Operation != operationUpload {
233 if body.HashAlgo != hashAlgoSHA256 { 366 makeRespError(ctx, w, "Invalid operation specified", http.StatusBadRequest)
234 makeRespError(ctx, w, "Unsupported hash algorithm specified", http.StatusConflict)
235 return 367 return
236 } 368 }
237 369
238 // TODO: handle authentication 370 or := operationRequest{
239 // right now, we're just trying to make everything publically accessible 371 operation: body.Operation,
240 if body.Operation == operationUpload { 372 repository: repo,
241 makeRespError(ctx, w, "Upload operations are currently not supported", http.StatusForbidden)
242 return
243 } 373 }
244 374 if body.Ref != nil {
245 if len(body.Transfers) != 0 && !slices.Contains(body.Transfers, transferAdapterBasic) { 375 or.refspec = &body.Ref.Name
246 makeRespError(ctx, w, "Unsupported transfer adapter specified (supported: basic)", http.StatusConflict) 376 }
377 if !h.authorize(ctx, w, r, or) {
247 return 378 return
248 } 379 }
249 380
250 gitoliteArgs := []string{"access", "-q", repo, h.anonUser, "R"} 381 if body.HashAlgo != hashAlgoSHA256 {
251 if body.Ref != nil && body.Ref.Name != "" { 382 makeRespError(ctx, w, "Unsupported hash algorithm specified", http.StatusConflict)
252 gitoliteArgs = append(gitoliteArgs, body.Ref.Name)
253 }
254 cmd := exec.Command(h.gitolitePath, gitoliteArgs...)
255 err := cmd.Run()
256 permGranted := err == nil
257 var exitErr *exec.ExitError
258 if err != nil && !errors.As(err, &exitErr) {
259 reqlog(ctx, "Error checking access info (running %s): %s", cmd, err)
260 makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError)
261 return 383 return
262 } 384 }
263 if !permGranted { 385
264 // TODO: when handling authorization, make sure to return 403 Forbidden 386 if len(body.Transfers) != 0 && !slices.Contains(body.Transfers, transferAdapterBasic) {
265 // here when the user *does* have read permissions, but is not allowed 387 makeRespError(ctx, w, "Unsupported transfer adapter specified (supported: basic)", http.StatusConflict)
266 // to write when requesting an upload operation.
267 makeRespError(ctx, w, "Repository not found", http.StatusNotFound)
268 return 388 return
269 } 389 }
270 390
@@ -288,7 +408,12 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
288 HashAlgo: hashAlgoSHA256, 408 HashAlgo: hashAlgoSHA256,
289 } 409 }
290 for _, obj := range objects { 410 for _, obj := range objects {
291 resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj)) 411 switch body.Operation {
412 case operationDownload:
413 resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj))
414 case operationUpload:
415 resp.Objects = append(resp.Objects, h.handleUploadObject(ctx, repo, obj))
416 }
292 } 417 }
293 418
294 w.Header().Set("Content-Type", lfsMIME) 419 w.Header().Set("Content-Type", lfsMIME)
@@ -316,6 +441,23 @@ func die(msg string, args ...any) {
316 os.Exit(1) 441 os.Exit(1)
317} 442}
318 443
444func loadPublicKey(path string) ed25519.PublicKey {
445 raw, err := os.ReadFile(path)
446 if err != nil {
447 die("Failed to open specified public key: %s", err)
448 }
449 raw = bytes.TrimSpace(raw)
450
451 if hex.DecodedLen(len(raw)) != ed25519.PublicKeySize {
452 die("Specified public key file does not contain key of appropriate length")
453 }
454 decoded := make([]byte, hex.DecodedLen(len(raw)))
455 if _, err = hex.Decode(decoded, raw); err != nil {
456 die("Failed to decode specified public key: %s", err)
457 }
458 return decoded
459}
460
319func main() { 461func main() {
320 log("Environment variables:") 462 log("Environment variables:")
321 for _, s := range os.Environ() { 463 for _, s := range os.Environ() {
@@ -323,6 +465,7 @@ func main() {
323 } 465 }
324 466
325 anonUser := os.Getenv("ANON_USER") 467 anonUser := os.Getenv("ANON_USER")
468 publicKeyPath := os.Getenv("GITOLFS3_PUBLIC_KEY_PATH")
326 endpoint := os.Getenv("S3_ENDPOINT") 469 endpoint := os.Getenv("S3_ENDPOINT")
327 bucket := os.Getenv("S3_BUCKET") 470 bucket := os.Getenv("S3_BUCKET")
328 accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE") 471 accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE")
@@ -336,6 +479,9 @@ func main() {
336 if anonUser == "" { 479 if anonUser == "" {
337 die("Fatal: expected environment variable ANON_USER to be set") 480 die("Fatal: expected environment variable ANON_USER to be set")
338 } 481 }
482 if publicKeyPath == "" {
483 die("Fatal: expected environment variable GITOLFS3_PUBLIC_KEY_PATH to be set")
484 }
339 if endpoint == "" { 485 if endpoint == "" {
340 die("Fatal: expected environment variable S3_ENDPOINT to be set") 486 die("Fatal: expected environment variable S3_ENDPOINT to be set")
341 } 487 }
@@ -359,6 +505,8 @@ func main() {
359 die("Fatal: failed to read secret access key from specified file: %s", err) 505 die("Fatal: failed to read secret access key from specified file: %s", err)
360 } 506 }
361 507
508 publicKey := loadPublicKey(publicKeyPath)
509
362 mc, err := minio.New(endpoint, &minio.Options{ 510 mc, err := minio.New(endpoint, &minio.Options{
363 Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""), 511 Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""),
364 Secure: true, 512 Secure: true,
@@ -367,7 +515,7 @@ func main() {
367 die("Fatal: failed to create S3 client: %s", err) 515 die("Fatal: failed to create S3 client: %s", err)
368 } 516 }
369 517
370 if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath}); err != nil { 518 if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath, publicKey}); err != nil {
371 die("Fatal: failed to serve CGI: %s", err) 519 die("Fatal: failed to serve CGI: %s", err)
372 } 520 }
373} 521}