mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-28 11:14:54 -04:00
refactor: extract LFS JWT authentication into an authentication method
This commit is contained in:
parent
c8d24ff06a
commit
3328d7a69b
15 changed files with 322 additions and 312 deletions
|
|
@ -23,13 +23,13 @@ import (
|
|||
"forgejo.org/models/perm"
|
||||
"forgejo.org/modules/git"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/lfs"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/pprof"
|
||||
"forgejo.org/modules/private"
|
||||
"forgejo.org/modules/process"
|
||||
repo_module "forgejo.org/modules/repository"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/services/lfs"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/kballard/go-shellquote"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"time"
|
||||
|
||||
"forgejo.org/modules/util"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -117,3 +118,11 @@ type ErrorResponse struct {
|
|||
DocumentationURL string `json:"documentation_url,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
// Claims is a JWT Token Claims
|
||||
type Claims struct {
|
||||
RepoID int64
|
||||
Op string
|
||||
UserID int64
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,11 +105,15 @@ func optionsCorsHandler() func(next http.Handler) http.Handler {
|
|||
// earlier authentication success would prevent later authentication methods from being attempted.
|
||||
func buildAuthGroup() *auth_method.Group {
|
||||
group := auth_method.NewGroup()
|
||||
group.Add(&auth_method.OAuth2{}) // FIXME: this should be removed and only applied in download and oauth related routers
|
||||
group.Add(&auth_method.Basic{}) // FIXME: this should be removed and only applied in download and git/lfs routers
|
||||
|
||||
// FIXME: extracted from OAuth2 & Basic -- these methods have internal URL filters that should be moved into
|
||||
// middlewares (if we can figure out the right way to do that), similar to the notes on OAuth2 & Basic above.
|
||||
// FIXME: OAuth2, Basic, AccessToken, ActionRuntimeToken, ActionTaskToken authentication methods all use path
|
||||
// matching so that they only authenticate requests in specific endpoints -- in general these auth methods aren't
|
||||
// permitted for web endpoints, and aren't supported because the security models don't match. (For example, they
|
||||
// can have scoped permissions, which web UI handlers don't implement.) A clearer way to do this would be to apply
|
||||
// specific authentication methods to their auth middlewares. buildGitLfsAuthGroup and buildGitAuthGroup are the
|
||||
// start of transitioning to that model. Eventually these methods will be fully removed from buildAuthGroup:
|
||||
group.Add(&auth_method.OAuth2{})
|
||||
group.Add(&auth_method.Basic{})
|
||||
group.Add(&auth_method.AccessToken{})
|
||||
group.Add(&auth_method.ActionRuntimeToken{})
|
||||
group.Add(&auth_method.ActionTaskToken{})
|
||||
|
|
@ -122,8 +126,19 @@ func buildAuthGroup() *auth_method.Group {
|
|||
return group
|
||||
}
|
||||
|
||||
// Authentication methods that are applied to Git HTTP & Git LFS HTTP routes. They are processed in the order defined;
|
||||
// an earlier authentication success would prevent later authentication methods from being attempted.
|
||||
// Authentication methods that are applied to Git LFS HTTP routes. They are processed in the order defined; an earlier
|
||||
// authentication success would prevent later authentication methods from being attempted.
|
||||
func buildGitLfsAuthGroup() *auth_method.Group {
|
||||
group := auth_method.NewGroup()
|
||||
group.Add(&auth_method.LFSToken{})
|
||||
group.Add(&auth_method.Basic{})
|
||||
group.Add(&auth_method.AccessToken{})
|
||||
group.Add(&auth_method.ActionTaskToken{})
|
||||
return group
|
||||
}
|
||||
|
||||
// Authentication methods that are applied to Git HTTP routes. They are processed in the order defined; an earlier
|
||||
// authentication success would prevent later authentication methods from being attempted.
|
||||
func buildGitAuthGroup() *auth_method.Group {
|
||||
group := auth_method.NewGroup()
|
||||
group.Add(&auth_method.OAuth2{})
|
||||
|
|
@ -340,24 +355,10 @@ func Routes() *web.Route {
|
|||
// TODO: GetNotificationCount & GetActiveStopwatch really seem like things that could be folded into Contexter or as helper functions
|
||||
user.GetNotificationCount, repo.GetActiveStopwatch,
|
||||
goGet)
|
||||
|
||||
// /{username}/{repo}/info/lfs routes currently need to use buildAuthGroup(), not buildGitAuthGroup(), because:
|
||||
//
|
||||
// a) there are tests that use session auth to access the LFS endpoints (TestAPILFSLocksLogged), which may be a test
|
||||
// error, and
|
||||
//
|
||||
// b) if AuthorizedIntegration is included then JWTs generated by the LFS system with `setting.LFS.SigningKey` will
|
||||
// return AuthenticationAttemptedIncorrectCredential from AuthorizedIntegration, and cause the requests to 401
|
||||
// before reaching the LFS handlers.
|
||||
//
|
||||
// (a) is probably an error that can be fixed, and (b) should be fixed by changing LFS's JWT handling to be an
|
||||
// `auth_service.Method` implementation which would be incorporated into the auth group, so that LFS isn't doing
|
||||
// it's own "after the authentication" authentication. In the interm, it's split out from the `registerRoutes` call
|
||||
// above because at least fewer middlewares can be safely applied.
|
||||
routes.Group("",
|
||||
func() {
|
||||
registerGitLFSRoutes(routes)
|
||||
}, gzipMid, common.Sessioner(), context.Contexter(), webAuth(buildAuthGroup()), goGet)
|
||||
}, gzipMid, common.Sessioner(), context.Contexter(), webAuth(buildGitLfsAuthGroup()), goGet)
|
||||
routes.Group("",
|
||||
func() {
|
||||
registerGitRoutes(routes)
|
||||
|
|
|
|||
42
services/auth/method/auth_result_lfs_token.go
Normal file
42
services/auth/method/auth_result_lfs_token.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package method
|
||||
|
||||
import (
|
||||
auth_model "forgejo.org/models/auth"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/lfs"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/services/auth"
|
||||
"forgejo.org/services/authz"
|
||||
)
|
||||
|
||||
var _ auth.AuthenticationResult = &lfsTokenAuthenticationResult{}
|
||||
|
||||
type lfsTokenAuthenticationResult struct {
|
||||
*auth.BaseAuthenticationResult
|
||||
user *user_model.User
|
||||
claims *lfs.Claims
|
||||
}
|
||||
|
||||
func (r *lfsTokenAuthenticationResult) User() *user_model.User {
|
||||
return r.user
|
||||
}
|
||||
|
||||
func (r *lfsTokenAuthenticationResult) Scope() optional.Option[auth_model.AccessTokenScope] {
|
||||
if r.claims.Op == "download" {
|
||||
return optional.Some(auth_model.AccessTokenScopeReadRepository)
|
||||
}
|
||||
return optional.Some(auth_model.AccessTokenScopeWriteRepository)
|
||||
}
|
||||
|
||||
func (r *lfsTokenAuthenticationResult) Reducer() authz.AuthorizationReducer {
|
||||
return &authz.SpecificReposAuthorizationReducer{
|
||||
ResourceRepos: []authz.RepoGetter{r},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *lfsTokenAuthenticationResult) GetTargetRepoID() int64 {
|
||||
return r.claims.RepoID
|
||||
}
|
||||
65
services/auth/method/lfs_token.go
Normal file
65
services/auth/method/lfs_token.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package method
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/lfs"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/services/auth"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var _ auth.Method = &LFSToken{}
|
||||
|
||||
// LFSToken is an authentication method used when access a git repository over ssh which has LFS resources in-use. The
|
||||
// LFS client will issue a `git-lfs-authenticate` command over the ssh connection, and Forgejo will provide a
|
||||
// supplemental HTTP header "Authorization: Bearer ..." with a JWT. The LFS client can then make HTTP requests to LFS
|
||||
// endpoints with that supplemental header in order to inherit the permissions of the SSH user and to retrieve LFS
|
||||
// objects.
|
||||
type LFSToken struct{}
|
||||
|
||||
func (a *LFSToken) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput {
|
||||
hasToken, tokenText := tokenFromAuthorizationBearer(req).Get()
|
||||
if !hasToken {
|
||||
return &auth.AuthenticationNotAttempted{}
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenText, &lfs.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 &auth.AuthenticationAttemptedIncorrectCredential{Error: err}
|
||||
}
|
||||
|
||||
claims, claimsOk := token.Claims.(*lfs.Claims)
|
||||
if !token.Valid {
|
||||
return &auth.AuthenticationAttemptedIncorrectCredential{Error: errors.New("not a valid LFS JWT")}
|
||||
} else if !claimsOk {
|
||||
return &auth.AuthenticationError{Error: fmt.Errorf("claim object %v was not an lfs.Claims instance", token.Claims)}
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(req.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
return &auth.AuthenticationError{Error: fmt.Errorf("unable to load claim user %d: %w", claims.UserID, err)}
|
||||
}
|
||||
if !u.IsAccessAllowed(req.Context()) {
|
||||
return &auth.AuthenticationError{Error: fmt.Errorf("user access is not permitted")}
|
||||
}
|
||||
|
||||
return &auth.AuthenticationSuccess{
|
||||
Result: &lfsTokenAuthenticationResult{
|
||||
user: u,
|
||||
claims: claims,
|
||||
},
|
||||
}
|
||||
}
|
||||
160
services/auth/method/lfs_token_test.go
Normal file
160
services/auth/method/lfs_token_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package method
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/modules/lfs"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/services/auth"
|
||||
"forgejo.org/services/authz"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type authTokenOptions struct {
|
||||
Op string
|
||||
UserID int64
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
func getLFSAuthTokenWithBearer(opts authTokenOptions) (string, error) {
|
||||
now := time.Now()
|
||||
claims := lfs.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
RepoID: opts.RepoID,
|
||||
Op: opts.Op,
|
||||
UserID: opts.UserID,
|
||||
}
|
||||
|
||||
// Sign and get the complete encoded token as a string using the secret
|
||||
tokenString, err := setting.LFS.SigningKey.JWT(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign LFS JWT token: %w", err)
|
||||
}
|
||||
return "Bearer " + tokenString, nil
|
||||
}
|
||||
|
||||
func testAuthenticate(t *testing.T, cfg string) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
var err error
|
||||
setting.CfgProvider, err = setting.NewConfigProviderFromData(cfg)
|
||||
require.NoError(t, err, "Config")
|
||||
setting.LoadCommonSettings()
|
||||
|
||||
t.Run("no bearer token", func(t *testing.T) {
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
output := a.Verify(req, nil, nil)
|
||||
requireOutput[*auth.AuthenticationNotAttempted](t, output)
|
||||
})
|
||||
|
||||
t.Run("bearer not a JWT", func(t *testing.T) {
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", "Bearer abc")
|
||||
output := a.Verify(req, nil, nil)
|
||||
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
|
||||
require.ErrorContains(t, err, "token is malformed")
|
||||
})
|
||||
|
||||
t.Run("token valid op=download", func(t *testing.T) {
|
||||
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 1})
|
||||
require.NoError(t, err)
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", bearerAuth)
|
||||
output := a.Verify(req, nil, nil)
|
||||
result := requireOutput[*auth.AuthenticationSuccess](t, output).Result
|
||||
assert.EqualValues(t, 2, result.User().ID)
|
||||
assert.Equal(t, optional.Some(auth_model.AccessTokenScopeReadRepository), result.Scope())
|
||||
require.NotNil(t, result.Reducer())
|
||||
|
||||
// No direct way to query an authz.Reducer for its specific repos permitted, so this is a bit of a workaround to
|
||||
// see if it's targeting the repo from the JWT:
|
||||
repoGetter, isRepoGetter := result.(authz.RepoGetter)
|
||||
require.True(t, isRepoGetter)
|
||||
assert.EqualValues(t, 1, repoGetter.GetTargetRepoID())
|
||||
})
|
||||
|
||||
t.Run("token valid op=upload", func(t *testing.T) {
|
||||
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "upload", UserID: 2, RepoID: 24})
|
||||
require.NoError(t, err)
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", bearerAuth)
|
||||
output := a.Verify(req, nil, nil)
|
||||
result := requireOutput[*auth.AuthenticationSuccess](t, output).Result
|
||||
assert.EqualValues(t, 2, result.User().ID)
|
||||
assert.Equal(t, optional.Some(auth_model.AccessTokenScopeWriteRepository), result.Scope())
|
||||
require.NotNil(t, result.Reducer())
|
||||
|
||||
// No direct way to query an authz.Reducer for its specific repos permitted, so this is a bit of a workaround to
|
||||
// see if it's targeting the repo from the JWT:
|
||||
repoGetter, isRepoGetter := result.(authz.RepoGetter)
|
||||
require.True(t, isRepoGetter)
|
||||
assert.EqualValues(t, 24, repoGetter.GetTargetRepoID())
|
||||
})
|
||||
|
||||
t.Run("token signature malformed", func(t *testing.T) {
|
||||
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 1})
|
||||
bearerAuth += "malformed"
|
||||
|
||||
require.NoError(t, err)
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", bearerAuth)
|
||||
output := a.Verify(req, nil, nil)
|
||||
err = requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
|
||||
require.ErrorContains(t, err, "token signature is invalid")
|
||||
})
|
||||
|
||||
t.Run("invalid user", func(t *testing.T) {
|
||||
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 999, RepoID: 1})
|
||||
require.NoError(t, err)
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", bearerAuth)
|
||||
output := a.Verify(req, nil, nil)
|
||||
err = requireOutput[*auth.AuthenticationError](t, output).Error
|
||||
require.ErrorContains(t, err, "user does not exist")
|
||||
})
|
||||
}
|
||||
|
||||
type namedCfg struct {
|
||||
name, cfg string
|
||||
}
|
||||
|
||||
var iniCommon = `[security]
|
||||
INSTALL_LOCK = true
|
||||
INTERNAL_TOKEN = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
|
||||
[oauth2]
|
||||
JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
|
||||
[server]
|
||||
LFS_START_SERVER = true
|
||||
`
|
||||
|
||||
var cfgVariants = []namedCfg{
|
||||
{name: "HS256_default", cfg: `LFS_JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_`},
|
||||
{name: "RS256", cfg: `LFS_JWT_SIGNING_ALGORITHM = RS256`},
|
||||
}
|
||||
|
||||
func TestAuthenticate(t *testing.T) {
|
||||
for _, v := range cfgVariants {
|
||||
cfg := iniCommon + v.cfg
|
||||
t.Run(v.name, func(t *testing.T) { testAuthenticate(t, cfg) })
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ func GetAuthorizationReducerForAccessToken(ctx context.Context, token *auth_mode
|
|||
for i, r := range repos {
|
||||
iface[i] = r
|
||||
}
|
||||
return &SpecificReposAuthorizationReducer{resourceRepos: iface}, nil
|
||||
return &SpecificReposAuthorizationReducer{ResourceRepos: iface}, nil
|
||||
}
|
||||
|
||||
// Validate that an access token's state is valid for creation. For example, that it doesn't have a conflicting set of
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ func TestGetAuthorizationReducerForAccessToken(t *testing.T) {
|
|||
require.True(t, ok)
|
||||
require.NotNil(t, specific)
|
||||
|
||||
require.Len(t, specific.resourceRepos, 1)
|
||||
assert.EqualValues(t, 1, specific.resourceRepos[0].GetTargetRepoID())
|
||||
require.Len(t, specific.ResourceRepos, 1)
|
||||
assert.EqualValues(t, 1, specific.ResourceRepos[0].GetTargetRepoID())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,5 +29,5 @@ func GetAuthorizationReducerForAuthorizedIntegration(ctx context.Context, ai *au
|
|||
for i, r := range repos {
|
||||
iface[i] = r
|
||||
}
|
||||
return &SpecificReposAuthorizationReducer{resourceRepos: iface}, nil
|
||||
return &SpecificReposAuthorizationReducer{ResourceRepos: iface}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ func TestGetAuthorizationReducerForAuthorizedIntegration(t *testing.T) {
|
|||
require.True(t, ok)
|
||||
require.NotNil(t, specific)
|
||||
|
||||
require.Len(t, specific.resourceRepos, 1)
|
||||
assert.EqualValues(t, 1, specific.resourceRepos[0].GetTargetRepoID())
|
||||
require.Len(t, specific.ResourceRepos, 1)
|
||||
assert.EqualValues(t, 1, specific.ResourceRepos[0].GetTargetRepoID())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import (
|
|||
// repositories that aren't listed among the specific repos, read-only access is permitted. For all other repos, no
|
||||
// access is permitted.
|
||||
type SpecificReposAuthorizationReducer struct {
|
||||
resourceRepos []RepoGetter
|
||||
ResourceRepos []RepoGetter
|
||||
}
|
||||
|
||||
type RepoGetter interface {
|
||||
|
|
@ -26,7 +26,7 @@ type RepoGetter interface {
|
|||
}
|
||||
|
||||
func (r *SpecificReposAuthorizationReducer) ReduceRepoAccess(ctx context.Context, repo *repo_model.Repository, accessMode perm.AccessMode) (perm.AccessMode, error) {
|
||||
for _, tokenRepo := range r.resourceRepos {
|
||||
for _, tokenRepo := range r.ResourceRepos {
|
||||
if tokenRepo.GetTargetRepoID() == repo.ID {
|
||||
// No restrictions as this repo is within the scope of the access token.
|
||||
return accessMode, nil
|
||||
|
|
@ -48,8 +48,8 @@ func (r *SpecificReposAuthorizationReducer) ReduceRepoAccess(ctx context.Context
|
|||
}
|
||||
|
||||
func (r *SpecificReposAuthorizationReducer) RepoReadAccessFilter() builder.Cond {
|
||||
repoIDs := make([]int64, len(r.resourceRepos))
|
||||
for i, tokenRepo := range r.resourceRepos {
|
||||
repoIDs := make([]int64, len(r.ResourceRepos))
|
||||
for i, tokenRepo := range r.ResourceRepos {
|
||||
repoIDs[i] = tokenRepo.GetTargetRepoID()
|
||||
}
|
||||
targetRepos := builder.In("repository.id", repoIDs)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func TestSpecificReposAuthorizationReducer(t *testing.T) {
|
|||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
reducer := &SpecificReposAuthorizationReducer{
|
||||
resourceRepos: []RepoGetter{
|
||||
ResourceRepos: []RepoGetter{
|
||||
&auth.AccessTokenResourceRepo{
|
||||
RepoID: 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func GetListLockHandler(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
authenticated := authenticate(ctx, repository, rv.Authorization, true, false)
|
||||
authenticated := authenticate(ctx, repository, true, false)
|
||||
if !authenticated {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
||||
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
|
||||
|
|
@ -135,7 +135,6 @@ func GetListLockHandler(ctx *context.Context) {
|
|||
func PostLockHandler(ctx *context.Context) {
|
||||
userName := ctx.Params("username")
|
||||
repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
|
||||
authorization := ctx.Req.Header.Get("Authorization")
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
|
||||
if err != nil {
|
||||
|
|
@ -153,7 +152,7 @@ func PostLockHandler(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
authenticated := authenticate(ctx, repository, authorization, true, true)
|
||||
authenticated := authenticate(ctx, repository, true, true)
|
||||
if !authenticated {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
||||
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
|
||||
|
|
@ -207,7 +206,6 @@ func PostLockHandler(ctx *context.Context) {
|
|||
func VerifyLockHandler(ctx *context.Context) {
|
||||
userName := ctx.Params("username")
|
||||
repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
|
||||
authorization := ctx.Req.Header.Get("Authorization")
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
|
||||
if err != nil {
|
||||
|
|
@ -225,7 +223,7 @@ func VerifyLockHandler(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
authenticated := authenticate(ctx, repository, authorization, true, true)
|
||||
authenticated := authenticate(ctx, repository, true, true)
|
||||
if !authenticated {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
||||
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
|
||||
|
|
@ -275,7 +273,6 @@ func VerifyLockHandler(ctx *context.Context) {
|
|||
func UnLockHandler(ctx *context.Context) {
|
||||
userName := ctx.Params("username")
|
||||
repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
|
||||
authorization := ctx.Req.Header.Get("Authorization")
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
|
||||
if err != nil {
|
||||
|
|
@ -293,7 +290,7 @@ func UnLockHandler(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
authenticated := authenticate(ctx, repository, authorization, true, true)
|
||||
authenticated := authenticate(ctx, repository, true, true)
|
||||
if !authenticated {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
||||
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
package lfs
|
||||
|
||||
import (
|
||||
stdCtx "context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
|
|
@ -27,15 +26,12 @@ import (
|
|||
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.
|
||||
|
|
@ -45,14 +41,6 @@ type requestContext struct {
|
|||
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))
|
||||
|
|
@ -452,7 +440,7 @@ func getAuthenticatedRepository(ctx *context.Context, rc *requestContext, requir
|
|||
return nil
|
||||
}
|
||||
|
||||
if !authenticate(ctx, repository, rc.Authorization, false, requireWrite) {
|
||||
if !authenticate(ctx, repository, false, requireWrite) {
|
||||
requireAuth(ctx)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -533,7 +521,7 @@ func writeStatusMessage(ctx *context.Context, status int, message string) {
|
|||
|
||||
// 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 {
|
||||
func authenticate(ctx *context.Context, repository *repo_model.Repository, requireSigned, requireWrite bool) bool {
|
||||
accessMode := perm.AccessModeRead
|
||||
if requireWrite {
|
||||
accessMode = perm.AccessModeWrite
|
||||
|
|
@ -555,92 +543,21 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
|
|||
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)
|
||||
// ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess. Usage of ctx.Authentication.Reducer()
|
||||
// or .Scope() is also unnecessary here in `authenticate` -- context.CheckRepoScopedToken is used after
|
||||
// authentication to provide authorization checks.
|
||||
repoPerm, 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)
|
||||
canRead := repoPerm.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
|
||||
}
|
||||
|
||||
if !u.IsAccessAllowed(ctx) {
|
||||
return nil, errors.New("user access is blocked")
|
||||
}
|
||||
|
||||
repoPerm, err := access_model.GetUserRepoPermission(ctx, target, u)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserRepoPermission[%d]: Error: %v", claims.UserID, err)
|
||||
return nil, err
|
||||
}
|
||||
if !repoPerm.CanAccess(mode, unit.TypeCode) {
|
||||
return nil, errors.New("user does not have access to the repository")
|
||||
}
|
||||
|
||||
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")
|
||||
return false
|
||||
}
|
||||
|
||||
func requireAuth(ctx *context.Context) {
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package lfs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
perm_model "forgejo.org/models/perm"
|
||||
repo_model "forgejo.org/models/repo"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/services/contexttest"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
|
||||
type authTokenOptions struct {
|
||||
Op string
|
||||
UserID int64
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
func getLFSAuthTokenWithBearer(opts authTokenOptions) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
RepoID: opts.RepoID,
|
||||
Op: opts.Op,
|
||||
UserID: opts.UserID,
|
||||
}
|
||||
|
||||
// Sign and get the complete encoded token as a string using the secret
|
||||
tokenString, err := setting.LFS.SigningKey.JWT(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign LFS JWT token: %w", err)
|
||||
}
|
||||
return "Bearer " + tokenString, nil
|
||||
}
|
||||
|
||||
func testAuthenticate(t *testing.T, cfg string) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
var err error
|
||||
setting.CfgProvider, err = setting.NewConfigProviderFromData(cfg)
|
||||
require.NoError(t, err, "Config")
|
||||
setting.LoadCommonSettings()
|
||||
assert.True(t, setting.LFS.StartServer, "LFS_START_SERVER = true")
|
||||
assert.NotNil(t, setting.LFS.SigningKey, "SigningKey initialized")
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
token2, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 1})
|
||||
_, token2, _ = strings.Cut(token2, " ")
|
||||
ctx, _ := contexttest.MockContext(t, "/")
|
||||
|
||||
t.Run("handleLFSToken", func(t *testing.T) {
|
||||
u, err := handleLFSToken(ctx, "", repo1, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, u)
|
||||
|
||||
u, err = handleLFSToken(ctx, "invalid", repo1, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, u)
|
||||
|
||||
u, err = handleLFSToken(ctx, token2, repo1, perm_model.AccessModeRead)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, u.ID)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken nonexistent user", func(t *testing.T) {
|
||||
tokenMissing, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 999, RepoID: 1})
|
||||
_, tokenMissing, _ = strings.Cut(tokenMissing, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenMissing, repo1, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user does not exist")
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken nonexistent repo", func(t *testing.T) {
|
||||
tokenBadRepo, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 999})
|
||||
_, tokenBadRepo, _ = strings.Cut(tokenBadRepo, " ")
|
||||
badRepo := &repo_model.Repository{ID: 999}
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenBadRepo, badRepo, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken blocked user", func(t *testing.T) {
|
||||
tokenBlocked, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 37, RepoID: 1})
|
||||
_, tokenBlocked, _ = strings.Cut(tokenBlocked, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenBlocked, repo1, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user access is blocked")
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken no repo access", func(t *testing.T) {
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
tokenNoAccess, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 10, RepoID: 2})
|
||||
_, tokenNoAccess, _ = strings.Cut(tokenNoAccess, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenNoAccess, repo2, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not have access to the repository")
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken upload write access allowed", func(t *testing.T) {
|
||||
tokenUploadRW, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "upload", UserID: 2, RepoID: 1})
|
||||
_, tokenUploadRW, _ = strings.Cut(tokenUploadRW, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenUploadRW, repo1, perm_model.AccessModeWrite)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, u.ID)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken upload read-only access denied", func(t *testing.T) {
|
||||
tokenUploadRO, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "upload", UserID: 10, RepoID: 1})
|
||||
_, tokenUploadRO, _ = strings.Cut(tokenUploadRO, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenUploadRO, repo1, perm_model.AccessModeWrite)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not have access to the repository")
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken download read-only access allowed", func(t *testing.T) {
|
||||
tokenDownloadRO, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 10, RepoID: 1})
|
||||
_, tokenDownloadRO, _ = strings.Cut(tokenDownloadRO, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenDownloadRO, repo1, perm_model.AccessModeRead)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 10, u.ID)
|
||||
})
|
||||
|
||||
t.Run("authenticate", func(t *testing.T) {
|
||||
const prefixBearer = "Bearer "
|
||||
assert.False(t, authenticate(ctx, repo1, "", true, false))
|
||||
assert.False(t, authenticate(ctx, repo1, prefixBearer+"invalid", true, false))
|
||||
assert.True(t, authenticate(ctx, repo1, prefixBearer+token2, true, false))
|
||||
})
|
||||
}
|
||||
|
||||
type namedCfg struct {
|
||||
name, cfg string
|
||||
}
|
||||
|
||||
var iniCommon = `[security]
|
||||
INSTALL_LOCK = true
|
||||
INTERNAL_TOKEN = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
|
||||
[oauth2]
|
||||
JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
|
||||
[server]
|
||||
LFS_START_SERVER = true
|
||||
`
|
||||
|
||||
var cfgVariants = []namedCfg{
|
||||
{name: "HS256_default", cfg: `LFS_JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_`},
|
||||
{name: "RS256", cfg: `LFS_JWT_SIGNING_ALGORITHM = RS256`},
|
||||
}
|
||||
|
||||
func TestAuthenticate(t *testing.T) {
|
||||
for _, v := range cfgVariants {
|
||||
cfg := iniCommon + v.cfg
|
||||
t.Run(v.name, func(t *testing.T) { testAuthenticate(t, cfg) })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue