## Context the three commits in this series are the first step towards the goal of removing the special casing around `JWT_SECRET`, which is used for various modules via `GetGeneralTokenSigningSecret()`. Ultimately, I want to work towards enabling seamless migration away from general use of the common secret. To enable this, we need proper secret/key rotation support, that is, we need to allow for configuration of additional secrets/keys which are accepted for token validation, but not used to issue tokens. I have this _Verifier_ support basically implemented, but this PR is not it. This PR contains cleanup refactoring which I worked on before writing the _Verifier_ support, because I noticed that the existing secret/key handling across modules was inconsistent and required duplicated code. I am submitting this part now to allow for incremental review of not too large a diff, and because these commits remained unchanged during two weeks since I moved on the the next task. ## The problem being addressed Configuration of JWT signing secrets/keys was inconsistent: Under `[oauth2]` the full configuration set was supported: - `JWT_SIGNING_ALGORITHM` configured the algorithm - `JWT_SECRET` configured a literal secret for symmetric algorithms - `JWT_SECRET_URI` configured a `file:` uri of a secret for symmetric algorithms - `JWT_SIGNING_PRIVATE_KEY_FILE` configured a file for asymmetric algorithms For `[server]`, the LFS module only supported `LFS_JWT_SECRET`, and the signing method was hardcoded to `HS256` For `[actions]`, only asymmetric signing methods were supported via `ID_TOKEN_SIGNING_ALGORITHM` and `ID_TOKEN_SIGNING_PRIVATE_KEY_FILE`. ## ini unification The proposed code centralizes ini parsing to always support the following ini keys: - `[pfx]SIGNING_ALGORITHM` determines the algorithm - `[pfx]SECRET` is a literal secret for symmetric algorithms - `[pfx]SECRET_URI` is the uri of a secret for symmetric algorithms - `[pfx]SIGNING_PRIVATE_KEY_FILE` is a file with a private key for asymmetric algorithms `[pfx]` is specific to the module and chosen to support the existing ini keys Centralizing this code and unifying the ini keys will come handy for at least the following reasons: - consistent behavior across modules is easier to understand - less duplicated code - easier to expand later, which is my main motivation ## implementation notes as might be apparent by the _take3_ branch name, this is the third iteration of this patch series. The main reason why I abandoned the other two is that I first tried to move all the key initialization into the code called from settings.go when the ini file is parsed. But that lead to a lot of friction with test cases, because private key files which are configured, but do not exist will get created and hence require a writable `AppDataPath` and additional clean up. To avoid a lot of noise and complications in test cases, I kept the existing two stage process, where - the settings component creates missing symmetric signing keys and writes them to the .ini - the settings component creates a simple configuration struct - which is then used from the module init to create the actual key, which also includes creating a private key file if asymmetric crypto is configured and the key file does not exist. I would have wished this patch was a net negative in terms of LOCs, but I hope it contributes to clarity and many added lines are in test cases. ## Commits Because sometimes PRs are merged as squashes with the PR text remaining, I am repeating here the individual messages of the individual commits for future reference: ### Refactor signing key initalization and oauth2 use of it This commit is the first in a series towards the goal of addressing the FIXME comment in modules/setting/oauth2.go to remove GeneralTokenSigningSecret To do it properly, the task also requires addition of signing secret/key rotation: We ultimately want to be able to change a signing key, but continue to accept the previous one. This is particularly relevant to offer a path from GeneralTokenSigningSecret aka JWT_SECRET to new, specific component key configuration, where it should be possible to add the former JWT_SECRET as a key accepted for verification to enable a seamless transition. This perspective, in turn, calls for refactoring of the existing secret initialization code to centralize the common functions of parsing signing key related configuration directives: The oauth2 module currently is the only component accepting symmetric and asymmetric keys, with the limitation of the symmetric key being also the GeneralTokenSigningSecret. Other components either enforce HS256 or public key algorithms. We should really give the choice of algorithm selection and avoid code duplication in other places, so this commit - generalizes setting parsing into a configuration struct: A prefix can be provided, with which the common configuration directives are processed: - [pfx]SIGNING_ALGORITHM determines the algorithm - [pfx]SECRET is a literal secret for symmetric algorithms - [pfx]SECRET_URI is the uri of a secret for symmetric algorithms - [pfx]SIGNING_PRIVATE_KEY_FILE is a file with a private key for asymmetric algorithms - which is then accepted by jwtx.InitSigningKey() to create an actual signing key The reasons for the two stage process are explained in a long-ish comment in modules/setting/security.go. In short, other options would either violate sensible module boundaries or cause too much friction. These other options have actually been tried, this is take 3 of the proposed changes. ### Refactor services/lfs: Change token code to use SigningKey This now also enables use of token algorithms other than HS256. In this case, signing key initialization also happens during settings initialization, because LFS is also used in CLI commands. ### Refactor api/actions to use new signingkey API This now also enables use of symmetric token algorithms. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11194 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: Nils Goroll <nils.goroll@uplex.de> Co-committed-by: Nils Goroll <nils.goroll@uplex.de>
637 lines
19 KiB
Go
637 lines
19 KiB
Go
// Copyright 2021 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package lfs
|
|
|
|
import (
|
|
stdCtx "context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
actions_model "forgejo.org/models/actions"
|
|
auth_model "forgejo.org/models/auth"
|
|
git_model "forgejo.org/models/git"
|
|
"forgejo.org/models/perm"
|
|
access_model "forgejo.org/models/perm/access"
|
|
quota_model "forgejo.org/models/quota"
|
|
repo_model "forgejo.org/models/repo"
|
|
"forgejo.org/models/unit"
|
|
user_model "forgejo.org/models/user"
|
|
"forgejo.org/modules/json"
|
|
lfs_module "forgejo.org/modules/lfs"
|
|
"forgejo.org/modules/log"
|
|
"forgejo.org/modules/setting"
|
|
"forgejo.org/modules/storage"
|
|
"forgejo.org/services/context"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// requestContext contain variables from the HTTP request.
|
|
type requestContext struct {
|
|
User string
|
|
Repo string
|
|
Authorization string
|
|
}
|
|
|
|
// Claims is a JWT Token Claims
|
|
type Claims struct {
|
|
RepoID int64
|
|
Op string
|
|
UserID int64
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// DownloadLink builds a URL to download the object.
|
|
func (rc *requestContext) DownloadLink(p lfs_module.Pointer) string {
|
|
return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid))
|
|
}
|
|
|
|
// UploadLink builds a URL to upload the object.
|
|
func (rc *requestContext) UploadLink(p lfs_module.Pointer) string {
|
|
return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid), strconv.FormatInt(p.Size, 10))
|
|
}
|
|
|
|
// VerifyLink builds a URL for verifying the object.
|
|
func (rc *requestContext) VerifyLink(p lfs_module.Pointer) string {
|
|
return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/verify")
|
|
}
|
|
|
|
// CheckAcceptMediaType checks if the client accepts the LFS media type.
|
|
func CheckAcceptMediaType(ctx *context.Context) {
|
|
mediaParts := strings.Split(ctx.Req.Header.Get("Accept"), ";")
|
|
|
|
if mediaParts[0] != lfs_module.MediaType {
|
|
log.Trace("Calling a LFS method without accepting the correct media type: %s", lfs_module.MediaType)
|
|
writeStatus(ctx, http.StatusUnsupportedMediaType)
|
|
return
|
|
}
|
|
}
|
|
|
|
var rangeHeaderRegexp = regexp.MustCompile(`bytes=(\d+)\-(\d*).*`)
|
|
|
|
// DownloadHandler gets the content from the content store
|
|
func DownloadHandler(ctx *context.Context) {
|
|
rc := getRequestContext(ctx)
|
|
p := lfs_module.Pointer{Oid: ctx.Params("oid")}
|
|
|
|
meta := getAuthenticatedMeta(ctx, rc, p, false)
|
|
if meta == nil {
|
|
return
|
|
}
|
|
|
|
// Support resume download using Range header
|
|
var fromByte, toByte int64
|
|
toByte = meta.Size - 1
|
|
statusCode := http.StatusOK
|
|
if rangeHdr := ctx.Req.Header.Get("Range"); rangeHdr != "" {
|
|
match := rangeHeaderRegexp.FindStringSubmatch(rangeHdr)
|
|
if len(match) > 1 {
|
|
statusCode = http.StatusPartialContent
|
|
fromByte, _ = strconv.ParseInt(match[1], 10, 32)
|
|
|
|
if fromByte >= meta.Size {
|
|
writeStatus(ctx, http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
|
|
if match[2] != "" {
|
|
_toByte, _ := strconv.ParseInt(match[2], 10, 32)
|
|
if _toByte >= fromByte && _toByte < toByte {
|
|
toByte = _toByte
|
|
}
|
|
}
|
|
|
|
ctx.Resp.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", fromByte, toByte, meta.Size-fromByte))
|
|
ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Range")
|
|
}
|
|
}
|
|
|
|
contentStore := lfs_module.NewContentStore()
|
|
content, err := contentStore.Get(meta.Pointer)
|
|
if err != nil {
|
|
writeStatus(ctx, http.StatusNotFound)
|
|
return
|
|
}
|
|
defer content.Close()
|
|
|
|
if fromByte > 0 {
|
|
_, err = content.Seek(fromByte, io.SeekStart)
|
|
if err != nil {
|
|
log.Error("Whilst trying to read LFS OID[%s]: Unable to seek to %d Error: %v", meta.Oid, fromByte, err)
|
|
writeStatus(ctx, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
contentLength := toByte + 1 - fromByte
|
|
ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
|
|
ctx.Resp.Header().Set("Content-Type", "application/octet-stream")
|
|
|
|
filename := ctx.Params("filename")
|
|
if len(filename) > 0 {
|
|
decodedFilename, err := base64.RawURLEncoding.DecodeString(filename)
|
|
if err == nil {
|
|
ctx.Resp.Header().Set("Content-Disposition", "attachment; filename=\""+string(decodedFilename)+"\"")
|
|
ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
|
|
}
|
|
}
|
|
|
|
ctx.Resp.WriteHeader(statusCode)
|
|
if written, err := io.CopyN(ctx.Resp, content, contentLength); err != nil {
|
|
log.Error("Error whilst copying LFS OID[%s] to the response after %d bytes. Error: %v", meta.Oid, written, err)
|
|
}
|
|
}
|
|
|
|
// BatchHandler provides the batch api
|
|
func BatchHandler(ctx *context.Context) {
|
|
var br lfs_module.BatchRequest
|
|
if err := decodeJSON(ctx.Req, &br); err != nil {
|
|
log.Trace("Unable to decode BATCH request vars: Error: %v", err)
|
|
writeStatus(ctx, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var isUpload bool
|
|
switch br.Operation {
|
|
case "upload":
|
|
isUpload = true
|
|
case "download":
|
|
isUpload = false
|
|
default:
|
|
log.Trace("Attempt to BATCH with invalid operation: %s", br.Operation)
|
|
writeStatus(ctx, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
rc := getRequestContext(ctx)
|
|
|
|
repository := getAuthenticatedRepository(ctx, rc, isUpload)
|
|
if repository == nil {
|
|
return
|
|
}
|
|
|
|
if isUpload {
|
|
ok, err := quota_model.EvaluateForUser(ctx, ctx.Doer.ID, quota_model.LimitSubjectSizeGitLFS)
|
|
if err != nil {
|
|
log.Error("quota_model.EvaluateForUser: %v", err)
|
|
writeStatus(ctx, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !ok {
|
|
writeStatusMessage(ctx, http.StatusRequestEntityTooLarge, "quota exceeded")
|
|
}
|
|
}
|
|
|
|
if setting.LFS.MaxBatchSize != 0 && len(br.Objects) > setting.LFS.MaxBatchSize {
|
|
writeStatus(ctx, http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
|
|
contentStore := lfs_module.NewContentStore()
|
|
|
|
var responseObjects []*lfs_module.ObjectResponse
|
|
|
|
for _, p := range br.Objects {
|
|
if !p.IsValid() {
|
|
responseObjects = append(responseObjects, buildObjectResponse(rc, p, false, false, &lfs_module.ObjectError{
|
|
Code: http.StatusUnprocessableEntity,
|
|
Message: "Oid or size are invalid",
|
|
}))
|
|
continue
|
|
}
|
|
|
|
exists, err := contentStore.Exists(p)
|
|
if err != nil {
|
|
log.Error("Unable to check if LFS OID[%s] exist. Error: %v", p.Oid, rc.User, rc.Repo, err)
|
|
writeStatus(ctx, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
meta, err := git_model.GetLFSMetaObjectByOid(ctx, repository.ID, p.Oid)
|
|
if err != nil && err != git_model.ErrLFSObjectNotExist {
|
|
log.Error("Unable to get LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err)
|
|
writeStatus(ctx, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if meta != nil && p.Size != meta.Size {
|
|
responseObjects = append(responseObjects, buildObjectResponse(rc, p, false, false, &lfs_module.ObjectError{
|
|
Code: http.StatusUnprocessableEntity,
|
|
Message: fmt.Sprintf("Object %s is not %d bytes", p.Oid, p.Size),
|
|
}))
|
|
continue
|
|
}
|
|
|
|
var responseObject *lfs_module.ObjectResponse
|
|
if isUpload {
|
|
var err *lfs_module.ObjectError
|
|
if !exists && setting.LFS.MaxFileSize > 0 && p.Size > setting.LFS.MaxFileSize {
|
|
err = &lfs_module.ObjectError{
|
|
Code: http.StatusUnprocessableEntity,
|
|
Message: fmt.Sprintf("Size must be less than or equal to %d", setting.LFS.MaxFileSize),
|
|
}
|
|
}
|
|
|
|
if exists && meta == nil {
|
|
accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
|
|
if err != nil {
|
|
log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err)
|
|
writeStatus(ctx, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if accessible {
|
|
_, err := git_model.NewLFSMetaObject(ctx, repository.ID, p)
|
|
if err != nil {
|
|
log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err)
|
|
writeStatus(ctx, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
exists = false
|
|
}
|
|
}
|
|
|
|
responseObject = buildObjectResponse(rc, p, false, !exists, err)
|
|
} else {
|
|
var err *lfs_module.ObjectError
|
|
if !exists || meta == nil {
|
|
err = &lfs_module.ObjectError{
|
|
Code: http.StatusNotFound,
|
|
Message: http.StatusText(http.StatusNotFound),
|
|
}
|
|
}
|
|
|
|
responseObject = buildObjectResponse(rc, p, true, false, err)
|
|
}
|
|
responseObjects = append(responseObjects, responseObject)
|
|
}
|
|
|
|
respobj := &lfs_module.BatchResponse{Objects: responseObjects}
|
|
|
|
ctx.Resp.Header().Set("Content-Type", lfs_module.MediaType)
|
|
|
|
enc := json.NewEncoder(ctx.Resp)
|
|
if err := enc.Encode(respobj); err != nil {
|
|
log.Error("Failed to encode representation as json. Error: %v", err)
|
|
}
|
|
}
|
|
|
|
// UploadHandler receives data from the client and puts it into the content store
|
|
func UploadHandler(ctx *context.Context) {
|
|
rc := getRequestContext(ctx)
|
|
|
|
p := lfs_module.Pointer{Oid: ctx.Params("oid")}
|
|
var err error
|
|
if p.Size, err = strconv.ParseInt(ctx.Params("size"), 10, 64); err != nil {
|
|
writeStatusMessage(ctx, http.StatusUnprocessableEntity, err.Error())
|
|
}
|
|
|
|
if !p.IsValid() {
|
|
log.Trace("Attempt to access invalid LFS OID[%s] in %s/%s", p.Oid, rc.User, rc.Repo)
|
|
writeStatus(ctx, http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
|
|
repository := getAuthenticatedRepository(ctx, rc, true)
|
|
if repository == nil {
|
|
return
|
|
}
|
|
|
|
contentStore := lfs_module.NewContentStore()
|
|
exists, err := contentStore.Exists(p)
|
|
if err != nil {
|
|
log.Error("Unable to check if LFS OID[%s] exist. Error: %v", p.Oid, err)
|
|
writeStatus(ctx, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if exists {
|
|
ok, err := quota_model.EvaluateForUser(ctx, ctx.Doer.ID, quota_model.LimitSubjectSizeGitLFS)
|
|
if err != nil {
|
|
log.Error("quota_model.EvaluateForUser: %v", err)
|
|
writeStatus(ctx, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !ok {
|
|
writeStatusMessage(ctx, http.StatusRequestEntityTooLarge, "quota exceeded")
|
|
}
|
|
}
|
|
|
|
uploadOrVerify := func() error {
|
|
if exists {
|
|
accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
|
|
if err != nil {
|
|
log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err)
|
|
return err
|
|
}
|
|
if !accessible {
|
|
// The file exists but the user has no access to it.
|
|
// The upload gets verified by hashing and size comparison to prove access to it.
|
|
hash := sha256.New()
|
|
written, err := io.Copy(hash, ctx.Req.Body)
|
|
if err != nil {
|
|
log.Error("Error creating hash. Error: %v", err)
|
|
return err
|
|
}
|
|
|
|
if written != p.Size {
|
|
return lfs_module.ErrSizeMismatch
|
|
}
|
|
if hex.EncodeToString(hash.Sum(nil)) != p.Oid {
|
|
return lfs_module.ErrHashMismatch
|
|
}
|
|
}
|
|
} else if err := contentStore.Put(p, ctx.Req.Body); err != nil {
|
|
log.Error("Error putting LFS MetaObject [%s] into content store. Error: %v", p.Oid, err)
|
|
return err
|
|
}
|
|
_, err := git_model.NewLFSMetaObject(ctx, repository.ID, p)
|
|
return err
|
|
}
|
|
|
|
defer ctx.Req.Body.Close()
|
|
if err := uploadOrVerify(); err != nil {
|
|
if errors.Is(err, lfs_module.ErrSizeMismatch) || errors.Is(err, lfs_module.ErrHashMismatch) {
|
|
log.Error("Upload does not match LFS MetaObject [%s]. Error: %v", p.Oid, err)
|
|
writeStatusMessage(ctx, http.StatusUnprocessableEntity, err.Error())
|
|
} else {
|
|
log.Error("Error whilst uploadOrVerify LFS OID[%s]: %v", p.Oid, err)
|
|
writeStatus(ctx, http.StatusInternalServerError)
|
|
}
|
|
if _, err = git_model.RemoveLFSMetaObjectByOid(ctx, repository.ID, p.Oid); err != nil {
|
|
log.Error("Error whilst removing MetaObject for LFS OID[%s]: %v", p.Oid, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
writeStatus(ctx, http.StatusOK)
|
|
}
|
|
|
|
// VerifyHandler verify oid and its size from the content store
|
|
func VerifyHandler(ctx *context.Context) {
|
|
var p lfs_module.Pointer
|
|
if err := decodeJSON(ctx.Req, &p); err != nil {
|
|
writeStatus(ctx, http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
|
|
rc := getRequestContext(ctx)
|
|
|
|
meta := getAuthenticatedMeta(ctx, rc, p, true)
|
|
if meta == nil {
|
|
return
|
|
}
|
|
|
|
contentStore := lfs_module.NewContentStore()
|
|
ok, err := contentStore.Verify(meta.Pointer)
|
|
|
|
status := http.StatusOK
|
|
if err != nil {
|
|
log.Error("Error whilst verifying LFS OID[%s]: %v", p.Oid, err)
|
|
status = http.StatusInternalServerError
|
|
} else if !ok {
|
|
status = http.StatusNotFound
|
|
}
|
|
writeStatus(ctx, status)
|
|
}
|
|
|
|
func decodeJSON(req *http.Request, v any) error {
|
|
defer req.Body.Close()
|
|
|
|
dec := json.NewDecoder(req.Body)
|
|
return dec.Decode(v)
|
|
}
|
|
|
|
func getRequestContext(ctx *context.Context) *requestContext {
|
|
return &requestContext{
|
|
User: ctx.Params("username"),
|
|
Repo: strings.TrimSuffix(ctx.Params("reponame"), ".git"),
|
|
Authorization: ctx.Req.Header.Get("Authorization"),
|
|
}
|
|
}
|
|
|
|
func getAuthenticatedMeta(ctx *context.Context, rc *requestContext, p lfs_module.Pointer, requireWrite bool) *git_model.LFSMetaObject {
|
|
if !p.IsValid() {
|
|
log.Info("Attempt to access invalid LFS OID[%s] in %s/%s", p.Oid, rc.User, rc.Repo)
|
|
writeStatusMessage(ctx, http.StatusUnprocessableEntity, "Oid or size are invalid")
|
|
return nil
|
|
}
|
|
|
|
repository := getAuthenticatedRepository(ctx, rc, requireWrite)
|
|
if repository == nil {
|
|
return nil
|
|
}
|
|
|
|
meta, err := git_model.GetLFSMetaObjectByOid(ctx, repository.ID, p.Oid)
|
|
if err != nil {
|
|
log.Error("Unable to get LFS OID[%s] Error: %v", p.Oid, err)
|
|
writeStatus(ctx, http.StatusNotFound)
|
|
return nil
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
func getAuthenticatedRepository(ctx *context.Context, rc *requestContext, requireWrite bool) *repo_model.Repository {
|
|
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, rc.User, rc.Repo)
|
|
if err != nil {
|
|
log.Error("Unable to get repository: %s/%s Error: %v", rc.User, rc.Repo, err)
|
|
writeStatus(ctx, http.StatusNotFound)
|
|
return nil
|
|
}
|
|
|
|
if !authenticate(ctx, repository, rc.Authorization, false, requireWrite) {
|
|
requireAuth(ctx)
|
|
return nil
|
|
}
|
|
|
|
if requireWrite {
|
|
context.CheckRepoScopedToken(ctx, repository, auth_model.Write)
|
|
} else {
|
|
context.CheckRepoScopedToken(ctx, repository, auth_model.Read)
|
|
}
|
|
|
|
if ctx.Written() {
|
|
return nil
|
|
}
|
|
|
|
return repository
|
|
}
|
|
|
|
func buildObjectResponse(rc *requestContext, pointer lfs_module.Pointer, download, upload bool, err *lfs_module.ObjectError) *lfs_module.ObjectResponse {
|
|
rep := &lfs_module.ObjectResponse{Pointer: pointer}
|
|
if err != nil {
|
|
rep.Error = err
|
|
} else {
|
|
rep.Actions = make(map[string]*lfs_module.Link)
|
|
|
|
header := make(map[string]string)
|
|
|
|
if len(rc.Authorization) > 0 {
|
|
header["Authorization"] = rc.Authorization
|
|
}
|
|
|
|
if download {
|
|
var link *lfs_module.Link
|
|
if setting.LFS.Storage.MinioConfig.ServeDirect {
|
|
// If we have a signed url (S3, object storage), redirect to this directly.
|
|
u, err := storage.LFS.URL(pointer.RelativePath(), pointer.Oid, nil)
|
|
if u != nil && err == nil {
|
|
// Presigned url does not need the Authorization header
|
|
// https://github.com/go-gitea/gitea/issues/21525
|
|
delete(header, "Authorization")
|
|
link = &lfs_module.Link{Href: u.String(), Header: header}
|
|
}
|
|
}
|
|
if link == nil {
|
|
link = &lfs_module.Link{Href: rc.DownloadLink(pointer), Header: header}
|
|
}
|
|
rep.Actions["download"] = link
|
|
}
|
|
if upload {
|
|
rep.Actions["upload"] = &lfs_module.Link{Href: rc.UploadLink(pointer), Header: header}
|
|
|
|
verifyHeader := make(map[string]string)
|
|
maps.Copy(verifyHeader, header)
|
|
|
|
// This is only needed to workaround https://github.com/git-lfs/git-lfs/issues/3662
|
|
verifyHeader["Accept"] = lfs_module.AcceptHeader
|
|
|
|
rep.Actions["verify"] = &lfs_module.Link{Href: rc.VerifyLink(pointer), Header: verifyHeader}
|
|
}
|
|
}
|
|
return rep
|
|
}
|
|
|
|
func writeStatus(ctx *context.Context, status int) {
|
|
writeStatusMessage(ctx, status, http.StatusText(status))
|
|
}
|
|
|
|
func writeStatusMessage(ctx *context.Context, status int, message string) {
|
|
ctx.Resp.Header().Set("Content-Type", lfs_module.MediaType)
|
|
ctx.Resp.WriteHeader(status)
|
|
|
|
er := lfs_module.ErrorResponse{Message: message}
|
|
|
|
enc := json.NewEncoder(ctx.Resp)
|
|
if err := enc.Encode(er); err != nil {
|
|
log.Error("Failed to encode error response as json. Error: %v", err)
|
|
}
|
|
}
|
|
|
|
// authenticate uses the authorization string to determine whether
|
|
// or not to proceed. This server assumes an HTTP Basic auth format.
|
|
func authenticate(ctx *context.Context, repository *repo_model.Repository, authorization string, requireSigned, requireWrite bool) bool {
|
|
accessMode := perm.AccessModeRead
|
|
if requireWrite {
|
|
accessMode = perm.AccessModeWrite
|
|
}
|
|
|
|
if ctx.Data["IsActionsToken"] == true {
|
|
taskID := ctx.Data["ActionsTaskID"].(int64)
|
|
task, err := actions_model.GetTaskByID(ctx, taskID)
|
|
if err != nil {
|
|
log.Error("Unable to GetTaskByID for task[%d] Error: %v", taskID, err)
|
|
return false
|
|
}
|
|
if task.RepoID != repository.ID {
|
|
return false
|
|
}
|
|
|
|
if task.IsForkPullRequest {
|
|
return accessMode <= perm.AccessModeRead
|
|
}
|
|
return accessMode <= perm.AccessModeWrite
|
|
}
|
|
|
|
// ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess
|
|
perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer)
|
|
if err != nil {
|
|
log.Error("Unable to GetUserRepoPermission for user %-v in repo %-v Error: %v", ctx.Doer, repository, err)
|
|
return false
|
|
}
|
|
|
|
canRead := perm.CanAccess(accessMode, unit.TypeCode)
|
|
if canRead && (!requireSigned || ctx.IsSigned) {
|
|
return true
|
|
}
|
|
|
|
user, err := parseToken(ctx, authorization, repository, accessMode)
|
|
if err != nil {
|
|
// Most of these are Warn level - the true internal server errors are logged in parseToken already
|
|
log.Warn("Authentication failure for provided token with Error: %v", err)
|
|
return false
|
|
}
|
|
ctx.Doer = user
|
|
return true
|
|
}
|
|
|
|
func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repository, mode perm.AccessMode) (*user_model.User, error) {
|
|
token, err := jwt.ParseWithClaims(tokenSHA, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
k := setting.LFS.SigningKey
|
|
if t.Method != k.SigningMethod() {
|
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
|
}
|
|
return k.VerifyKey(), nil
|
|
})
|
|
if err != nil {
|
|
return nil, errors.New("invalid token")
|
|
}
|
|
|
|
claims, claimsOk := token.Claims.(*Claims)
|
|
if !token.Valid || !claimsOk {
|
|
return nil, errors.New("invalid token claim")
|
|
}
|
|
|
|
if claims.RepoID != target.ID {
|
|
return nil, errors.New("invalid token claim")
|
|
}
|
|
|
|
if mode == perm.AccessModeWrite && claims.Op != "upload" {
|
|
return nil, errors.New("invalid token claim")
|
|
}
|
|
|
|
u, err := user_model.GetUserByID(ctx, claims.UserID)
|
|
if err != nil {
|
|
log.Error("Unable to GetUserById[%d]: Error: %v", claims.UserID, err)
|
|
return nil, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func parseToken(ctx stdCtx.Context, authorization string, target *repo_model.Repository, mode perm.AccessMode) (*user_model.User, error) {
|
|
if authorization == "" {
|
|
return nil, errors.New("no token")
|
|
}
|
|
|
|
parts := strings.SplitN(authorization, " ", 2)
|
|
if len(parts) != 2 {
|
|
return nil, errors.New("no token")
|
|
}
|
|
tokenSHA := parts[1]
|
|
switch strings.ToLower(parts[0]) {
|
|
case "bearer":
|
|
fallthrough
|
|
case "token":
|
|
return handleLFSToken(ctx, tokenSHA, target, mode)
|
|
}
|
|
return nil, errors.New("token not found")
|
|
}
|
|
|
|
func requireAuth(ctx *context.Context) {
|
|
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
|
writeStatus(ctx, http.StatusUnauthorized)
|
|
}
|