|
|
|
// 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"
|
|
|
|
"net/http"
|
|
|
|
"net/url"
|
|
|
|
"path"
|
|
|
|
"regexp"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
actions_model "code.gitea.io/gitea/models/actions"
|
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
1 year ago
|
|
|
auth_model "code.gitea.io/gitea/models/auth"
|
|
|
|
git_model "code.gitea.io/gitea/models/git"
|
|
|
|
"code.gitea.io/gitea/models/perm"
|
|
|
|
access_model "code.gitea.io/gitea/models/perm/access"
|
|
|
|
repo_model "code.gitea.io/gitea/models/repo"
|
|
|
|
"code.gitea.io/gitea/models/unit"
|
|
|
|
user_model "code.gitea.io/gitea/models/user"
|
|
|
|
"code.gitea.io/gitea/modules/json"
|
|
|
|
lfs_module "code.gitea.io/gitea/modules/lfs"
|
|
|
|
"code.gitea.io/gitea/modules/log"
|
|
|
|
"code.gitea.io/gitea/modules/setting"
|
|
|
|
"code.gitea.io/gitea/modules/storage"
|
|
|
|
"code.gitea.io/gitea/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
|
|
|
|
if br.Operation == "upload" {
|
|
|
|
isUpload = true
|
|
|
|
} else if br.Operation == "download" {
|
|
|
|
isUpload = false
|
|
|
|
} else {
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
1 year ago
|
|
|
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)
|
|
|
|
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)
|
|
|
|
for key, value := range header {
|
|
|
|
verifyHeader[key] = value
|
|
|
|
}
|
|
|
|
|
|
|
|
// This is only needed to workaround https://github.com/git-lfs/git-lfs/issues/3662
|
|
|
|
verifyHeader["Accept"] = lfs_module.MediaType
|
|
|
|
|
|
|
|
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) {
|
|
|
|
if !strings.Contains(tokenSHA, ".") {
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
token, err := jwt.ParseWithClaims(tokenSHA, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
|
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
|
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
|
|
|
}
|
|
|
|
return setting.LFS.JWTSecretBytes, nil
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
claims, claimsOk := token.Claims.(*Claims)
|
|
|
|
if !token.Valid || !claimsOk {
|
|
|
|
return nil, fmt.Errorf("invalid token claim")
|
|
|
|
}
|
|
|
|
|
|
|
|
if claims.RepoID != target.ID {
|
|
|
|
return nil, fmt.Errorf("invalid token claim")
|
|
|
|
}
|
|
|
|
|
|
|
|
if mode == perm.AccessModeWrite && claims.Op != "upload" {
|
|
|
|
return nil, fmt.Errorf("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, fmt.Errorf("no token")
|
|
|
|
}
|
|
|
|
|
|
|
|
parts := strings.SplitN(authorization, " ", 2)
|
|
|
|
if len(parts) != 2 {
|
|
|
|
return nil, fmt.Errorf("no token")
|
|
|
|
}
|
|
|
|
tokenSHA := parts[1]
|
|
|
|
switch strings.ToLower(parts[0]) {
|
|
|
|
case "bearer":
|
|
|
|
fallthrough
|
|
|
|
case "token":
|
|
|
|
return handleLFSToken(ctx, tokenSHA, target, mode)
|
|
|
|
}
|
|
|
|
return nil, fmt.Errorf("token not found")
|
|
|
|
}
|
|
|
|
|
|
|
|
func requireAuth(ctx *context.Context) {
|
|
|
|
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
|
|
|
writeStatus(ctx, http.StatusUnauthorized)
|
|
|
|
}
|