diff options
| author | Rutger Broekhoff | 2024-01-02 17:16:55 +0100 |
|---|---|---|
| committer | Rutger Broekhoff | 2024-01-02 17:16:55 +0100 |
| commit | 6e97a3edaa18ef8e5b16feba29f04e993957b7a7 (patch) | |
| tree | 4766c21f58811728acd933aa5dc2045e90369ad6 /cmd | |
| parent | 91fcc2aeda01b7680cae826349e34eb7c8c1ec3b (diff) | |
| download | gitolfs3-6e97a3edaa18ef8e5b16feba29f04e993957b7a7.tar.gz gitolfs3-6e97a3edaa18ef8e5b16feba29f04e993957b7a7.zip | |
Token types, download verification
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/git-lfs-authenticate/main.go | 2 | ||||
| -rw-r--r-- | cmd/git-lfs-server/main.go | 158 |
2 files changed, 140 insertions, 20 deletions
diff --git a/cmd/git-lfs-authenticate/main.go b/cmd/git-lfs-authenticate/main.go index fc98246..d2dee21 100644 --- a/cmd/git-lfs-authenticate/main.go +++ b/cmd/git-lfs-authenticate/main.go | |||
| @@ -89,6 +89,7 @@ func getGitoliteAccess(logger *logger, reqID, path, user, gitolitePerm string) b | |||
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | type gitolfs3Claims struct { | 91 | type gitolfs3Claims struct { |
| 92 | Type string `json:"type"` | ||
| 92 | Repository string `json:"repository"` | 93 | Repository string `json:"repository"` |
| 93 | Permission string `json:"permission"` | 94 | Permission string `json:"permission"` |
| 94 | } | 95 | } |
| @@ -190,6 +191,7 @@ func main() { | |||
| 190 | expiresIn := time.Hour * 24 | 191 | expiresIn := time.Hour * 24 |
| 191 | claims := customClaims{ | 192 | claims := customClaims{ |
| 192 | Gitolfs3: gitolfs3Claims{ | 193 | Gitolfs3: gitolfs3Claims{ |
| 194 | Type: "batch-api", | ||
| 193 | Repository: repo, | 195 | Repository: repo, |
| 194 | Permission: operation, | 196 | Permission: operation, |
| 195 | }, | 197 | }, |
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index 191a696..c7feeff 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go | |||
| @@ -163,19 +163,35 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
| 163 | return makeObjError(obj, "Failed to query object information", http.StatusInternalServerError) | 163 | return makeObjError(obj, "Failed to query object information", http.StatusInternalServerError) |
| 164 | } | 164 | } |
| 165 | if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash { | 165 | if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash { |
| 166 | return makeObjError(obj, "Corrupted file", http.StatusUnprocessableEntity) | 166 | return makeObjError(obj, "Object corrupted", http.StatusUnprocessableEntity) |
| 167 | } | 167 | } |
| 168 | if info.Size != obj.size { | 168 | if info.Size != obj.size { |
| 169 | return makeObjError(obj, "Incorrect size specified for object", http.StatusUnprocessableEntity) | 169 | return makeObjError(obj, "Incorrect size specified for object or object currupted", http.StatusUnprocessableEntity) |
| 170 | } | 170 | } |
| 171 | 171 | ||
| 172 | presigned, err := h.mc.PresignedGetObject(ctx, h.bucket, fullPath, expiresIn, url.Values{}) | 172 | claims := handleObjectCustomClaims{ |
| 173 | Gitolfs3: handleObjectGitolfs3Claims{ | ||
| 174 | Type: "basic-transfer", | ||
| 175 | Operation: operationDownload, | ||
| 176 | Repository: repo, | ||
| 177 | OID: obj.fullHash, | ||
| 178 | Size: obj.size, | ||
| 179 | }, | ||
| 180 | RegisteredClaims: &jwt.RegisteredClaims{ | ||
| 181 | IssuedAt: jwt.NewNumericDate(time.Now()), | ||
| 182 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), | ||
| 183 | }, | ||
| 184 | } | ||
| 185 | |||
| 186 | token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) | ||
| 187 | ss, err := token.SignedString(h.privateKey) | ||
| 173 | if err != nil { | 188 | if err != nil { |
| 174 | // TODO: consider not making this an object-specific, but rather a | 189 | // TODO: consider not making this an object-specific, but rather a |
| 175 | // generic error such that the entire Batch API request fails. | 190 | // generic error such that the entire Batch API request fails. |
| 176 | reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err) | 191 | reqlog(ctx, "Fatal: failed to generate JWT: %s", err) |
| 177 | return makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError) | 192 | return makeObjError(obj, "Failed to generate token", http.StatusInternalServerError) |
| 178 | } | 193 | } |
| 194 | uploadPath := path.Join(repo+".git", "info/lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | ||
| 179 | 195 | ||
| 180 | authenticated := true | 196 | authenticated := true |
| 181 | return batchResponseObject{ | 197 | return batchResponseObject{ |
| @@ -184,21 +200,26 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
| 184 | Authenticated: &authenticated, | 200 | Authenticated: &authenticated, |
| 185 | Actions: map[operation]batchAction{ | 201 | Actions: map[operation]batchAction{ |
| 186 | operationDownload: { | 202 | operationDownload: { |
| 187 | HRef: presigned.String(), | 203 | Header: map[string]string{ |
| 204 | "Authorization": "Bearer " + ss, | ||
| 205 | }, | ||
| 206 | HRef: h.baseURL.ResolveReference(&url.URL{Path: uploadPath}).String(), | ||
| 188 | ExpiresIn: int64(expiresIn.Seconds()), | 207 | ExpiresIn: int64(expiresIn.Seconds()), |
| 189 | }, | 208 | }, |
| 190 | }, | 209 | }, |
| 191 | } | 210 | } |
| 192 | } | 211 | } |
| 193 | 212 | ||
| 194 | type uploadObjectGitolfs3Claims struct { | 213 | type handleObjectGitolfs3Claims struct { |
| 195 | Repository string `json:"repository"` | 214 | Type string `json:"type"` |
| 196 | OID string `json:"oid"` | 215 | Operation operation `json:"operation"` |
| 197 | Size int64 `json:"size"` | 216 | Repository string `json:"repository"` |
| 217 | OID string `json:"oid"` | ||
| 218 | Size int64 `json:"size"` | ||
| 198 | } | 219 | } |
| 199 | 220 | ||
| 200 | type uploadObjectCustomClaims struct { | 221 | type handleObjectCustomClaims struct { |
| 201 | Gitolfs3 uploadObjectGitolfs3Claims `json:"gitolfs3"` | 222 | Gitolfs3 handleObjectGitolfs3Claims `json:"gitolfs3"` |
| 202 | *jwt.RegisteredClaims | 223 | *jwt.RegisteredClaims |
| 203 | } | 224 | } |
| 204 | 225 | ||
| @@ -221,8 +242,10 @@ func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parse | |||
| 221 | } | 242 | } |
| 222 | 243 | ||
| 223 | expiresIn := time.Hour * 24 | 244 | expiresIn := time.Hour * 24 |
| 224 | claims := uploadObjectCustomClaims{ | 245 | claims := handleObjectCustomClaims{ |
| 225 | Gitolfs3: uploadObjectGitolfs3Claims{ | 246 | Gitolfs3: handleObjectGitolfs3Claims{ |
| 247 | Type: "basic-transfer", | ||
| 248 | Operation: operationUpload, | ||
| 226 | Repository: repo, | 249 | Repository: repo, |
| 227 | OID: obj.fullHash, | 250 | OID: obj.fullHash, |
| 228 | Size: obj.size, | 251 | Size: obj.size, |
| @@ -327,7 +350,7 @@ func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, | |||
| 327 | } | 350 | } |
| 328 | authz = strings.TrimPrefix(authz, "Bearer ") | 351 | authz = strings.TrimPrefix(authz, "Bearer ") |
| 329 | 352 | ||
| 330 | var claims uploadObjectCustomClaims | 353 | var claims handleObjectCustomClaims |
| 331 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | 354 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { |
| 332 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | 355 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { |
| 333 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | 356 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) |
| @@ -338,6 +361,10 @@ func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, | |||
| 338 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 361 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
| 339 | return | 362 | return |
| 340 | } | 363 | } |
| 364 | if claims.Gitolfs3.Type != "basic-transfer" { | ||
| 365 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 366 | return | ||
| 367 | } | ||
| 341 | if claims.Gitolfs3.Repository != repo { | 368 | if claims.Gitolfs3.Repository != repo { |
| 342 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 369 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
| 343 | return | 370 | return |
| @@ -346,6 +373,10 @@ func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, | |||
| 346 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 373 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
| 347 | return | 374 | return |
| 348 | } | 375 | } |
| 376 | if claims.Gitolfs3.Operation != operationUpload { | ||
| 377 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 378 | return | ||
| 379 | } | ||
| 349 | 380 | ||
| 350 | // Check with claims | 381 | // Check with claims |
| 351 | if lengthStr := r.Header.Get("Content-Length"); lengthStr != "" { | 382 | if lengthStr := r.Header.Get("Content-Length"); lengthStr != "" { |
| @@ -387,6 +418,85 @@ func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, | |||
| 387 | } | 418 | } |
| 388 | } | 419 | } |
| 389 | 420 | ||
| 421 | func (h *handler) handleGetObject(w http.ResponseWriter, r *http.Request, repo, oid string) { | ||
| 422 | ctx := r.Context() | ||
| 423 | |||
| 424 | authz := r.Header.Get("Authorization") | ||
| 425 | if authz == "" { | ||
| 426 | makeRespError(ctx, w, "Missing Authorization header", http.StatusBadRequest) | ||
| 427 | return | ||
| 428 | } | ||
| 429 | if !strings.HasPrefix(authz, "Bearer ") { | ||
| 430 | makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest) | ||
| 431 | return | ||
| 432 | } | ||
| 433 | authz = strings.TrimPrefix(authz, "Bearer ") | ||
| 434 | |||
| 435 | var claims handleObjectCustomClaims | ||
| 436 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | ||
| 437 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | ||
| 438 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | ||
| 439 | } | ||
| 440 | return h.privateKey.Public(), nil | ||
| 441 | }) | ||
| 442 | if err != nil { | ||
| 443 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 444 | return | ||
| 445 | } | ||
| 446 | if claims.Gitolfs3.Type != "basic-transfer" { | ||
| 447 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 448 | return | ||
| 449 | } | ||
| 450 | if claims.Gitolfs3.Repository != repo { | ||
| 451 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 452 | return | ||
| 453 | } | ||
| 454 | if claims.Gitolfs3.OID != oid { | ||
| 455 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 456 | return | ||
| 457 | } | ||
| 458 | if claims.Gitolfs3.Operation != operationDownload { | ||
| 459 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 460 | return | ||
| 461 | } | ||
| 462 | |||
| 463 | sha256Raw, err := hex.DecodeString(oid) | ||
| 464 | if err != nil || len(sha256Raw) != sha256.Size { | ||
| 465 | makeRespError(ctx, w, "Invalid OID", http.StatusBadRequest) | ||
| 466 | return | ||
| 467 | } | ||
| 468 | |||
| 469 | fullPath := path.Join(repo+".git", "lfs/objects", oid[:2], oid[2:4], oid) | ||
| 470 | obj, err := h.mc.GetObject(ctx, h.bucket, fullPath, minio.GetObjectOptions{}) | ||
| 471 | |||
| 472 | var resp minio.ErrorResponse | ||
| 473 | if errors.As(err, &resp) && resp.StatusCode != http.StatusNotFound { | ||
| 474 | makeRespError(ctx, w, "Not found", http.StatusNotFound) | ||
| 475 | return | ||
| 476 | } else if err != nil { | ||
| 477 | reqlog(ctx, "Failed to get object: %s", err) | ||
| 478 | makeRespError(ctx, w, "Failed to get object", http.StatusInternalServerError) | ||
| 479 | return | ||
| 480 | } | ||
| 481 | |||
| 482 | stat, err := obj.Stat() | ||
| 483 | if err != nil { | ||
| 484 | reqlog(ctx, "Failed to stat: %s", err) | ||
| 485 | makeRespError(ctx, w, "Internal server error", http.StatusInternalServerError) | ||
| 486 | } | ||
| 487 | |||
| 488 | if stat.Size != claims.Gitolfs3.Size { | ||
| 489 | reqlog(ctx, "Claims size does not match S3 object size") | ||
| 490 | makeRespError(ctx, w, "Internal server error", http.StatusInternalServerError) | ||
| 491 | } | ||
| 492 | |||
| 493 | vr := newValidatingReader(claims.Gitolfs3.Size, sha256Raw, obj) | ||
| 494 | _, err = io.Copy(w, vr) | ||
| 495 | if errors.Is(err, errBadSum) { | ||
| 496 | reqlog(ctx, "Bad object checksum") | ||
| 497 | } | ||
| 498 | } | ||
| 499 | |||
| 390 | type parsedBatchObject struct { | 500 | type parsedBatchObject struct { |
| 391 | firstByte string | 501 | firstByte string |
| 392 | secondByte string | 502 | secondByte string |
| @@ -414,6 +524,7 @@ var requestIDKey requestID | |||
| 414 | 524 | ||
| 415 | // TODO: make a shared package for this | 525 | // TODO: make a shared package for this |
| 416 | type lfsAuthGitolfs3Claims struct { | 526 | type lfsAuthGitolfs3Claims struct { |
| 527 | Type string `json:"type"` | ||
| 417 | Repository string `json:"repository"` | 528 | Repository string `json:"repository"` |
| 418 | Permission operation `json:"permission"` | 529 | Permission operation `json:"permission"` |
| 419 | } | 530 | } |
| @@ -448,7 +559,7 @@ func (h *handler) getGitoliteAccess(repo, user, gitolitePerm string, refspec *st | |||
| 448 | return true, nil | 559 | return true, nil |
| 449 | } | 560 | } |
| 450 | 561 | ||
| 451 | func (h *handler) authorize(w http.ResponseWriter, r *http.Request, or operationRequest) bool { | 562 | func (h *handler) authorizeBatchAPI(w http.ResponseWriter, r *http.Request, or operationRequest) bool { |
| 452 | user := h.anonUser | 563 | user := h.anonUser |
| 453 | ctx := r.Context() | 564 | ctx := r.Context() |
| 454 | 565 | ||
| @@ -471,6 +582,10 @@ func (h *handler) authorize(w http.ResponseWriter, r *http.Request, or operation | |||
| 471 | return false | 582 | return false |
| 472 | } | 583 | } |
| 473 | 584 | ||
| 585 | if claims.Gitolfs3.Type != "batch-api" { | ||
| 586 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 587 | return false | ||
| 588 | } | ||
| 474 | if claims.Gitolfs3.Repository != or.repository { | 589 | if claims.Gitolfs3.Repository != or.repository { |
| 475 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 590 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
| 476 | return false | 591 | return false |
| @@ -539,7 +654,7 @@ func (h *handler) handleBatchAPI(w http.ResponseWriter, r *http.Request, repo st | |||
| 539 | if body.Ref != nil { | 654 | if body.Ref != nil { |
| 540 | or.refspec = &body.Ref.Name | 655 | or.refspec = &body.Ref.Name |
| 541 | } | 656 | } |
| 542 | if !h.authorize(w, r.WithContext(ctx), or) { | 657 | if !h.authorizeBatchAPI(w, r.WithContext(ctx), or) { |
| 543 | return | 658 | return |
| 544 | } | 659 | } |
| 545 | 660 | ||
| @@ -632,12 +747,15 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 632 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") | 747 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") |
| 633 | reqlog(ctx, "Handling object PUT for repository: %s, OID: %s", repo, oid) | 748 | reqlog(ctx, "Handling object PUT for repository: %s, OID: %s", repo, oid) |
| 634 | 749 | ||
| 635 | if r.Method != http.MethodPut { | 750 | switch r.Method { |
| 751 | case http.MethodGet: | ||
| 752 | h.handleGetObject(w, r.WithContext(ctx), repo, oid) | ||
| 753 | case http.MethodPut: | ||
| 754 | h.handlePutObject(w, r.WithContext(ctx), repo, oid) | ||
| 755 | default: | ||
| 636 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) | 756 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) |
| 637 | return | ||
| 638 | } | 757 | } |
| 639 | 758 | ||
| 640 | h.handlePutObject(w, r.WithContext(ctx), repo, oid) | ||
| 641 | return | 759 | return |
| 642 | } | 760 | } |
| 643 | 761 | ||