diff --git a/cmd/serv.go b/cmd/serv.go index 3a92b0e5fb..8b410f3732 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -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" diff --git a/modules/lfs/shared.go b/modules/lfs/shared.go index 504a726bce..3e171643e1 100644 --- a/modules/lfs/shared.go +++ b/modules/lfs/shared.go @@ -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 +} diff --git a/routers/web/web.go b/routers/web/web.go index 3c1fac7f5f..d92b62def5 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/auth/method/auth_result_lfs_token.go b/services/auth/method/auth_result_lfs_token.go new file mode 100644 index 0000000000..28850f8093 --- /dev/null +++ b/services/auth/method/auth_result_lfs_token.go @@ -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 +} diff --git a/services/auth/method/lfs_token.go b/services/auth/method/lfs_token.go new file mode 100644 index 0000000000..fd0e51a79f --- /dev/null +++ b/services/auth/method/lfs_token.go @@ -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, + }, + } +} diff --git a/services/auth/method/lfs_token_test.go b/services/auth/method/lfs_token_test.go new file mode 100644 index 0000000000..bceb95bb64 --- /dev/null +++ b/services/auth/method/lfs_token_test.go @@ -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) }) + } +} diff --git a/services/authz/access_token.go b/services/authz/access_token.go index 11b492bc1e..e39d3449b7 100644 --- a/services/authz/access_token.go +++ b/services/authz/access_token.go @@ -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 diff --git a/services/authz/access_token_test.go b/services/authz/access_token_test.go index 2e4d9e4453..a20900b35b 100644 --- a/services/authz/access_token_test.go +++ b/services/authz/access_token_test.go @@ -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()) }) } diff --git a/services/authz/authorized_integration.go b/services/authz/authorized_integration.go index c463c0aeca..9dac65fa35 100644 --- a/services/authz/authorized_integration.go +++ b/services/authz/authorized_integration.go @@ -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 } diff --git a/services/authz/authorized_integration_test.go b/services/authz/authorized_integration_test.go index fe3ec697a4..fc1f193207 100644 --- a/services/authz/authorized_integration_test.go +++ b/services/authz/authorized_integration_test.go @@ -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()) }) } diff --git a/services/authz/specific_repos_reducer.go b/services/authz/specific_repos_reducer.go index b4e97f2a6b..5fa275689f 100644 --- a/services/authz/specific_repos_reducer.go +++ b/services/authz/specific_repos_reducer.go @@ -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) diff --git a/services/authz/specific_repos_reducer_test.go b/services/authz/specific_repos_reducer_test.go index a7bd1545b0..9ba44bb12a 100644 --- a/services/authz/specific_repos_reducer_test.go +++ b/services/authz/specific_repos_reducer_test.go @@ -20,7 +20,7 @@ func TestSpecificReposAuthorizationReducer(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) reducer := &SpecificReposAuthorizationReducer{ - resourceRepos: []RepoGetter{ + ResourceRepos: []RepoGetter{ &auth.AccessTokenResourceRepo{ RepoID: 1, }, diff --git a/services/lfs/locks.go b/services/lfs/locks.go index 16f6dc1631..0a9ddb1ee5 100644 --- a/services/lfs/locks.go +++ b/services/lfs/locks.go @@ -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{ diff --git a/services/lfs/server.go b/services/lfs/server.go index 1dfd58a3c0..34315ced0d 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -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) { diff --git a/services/lfs/server_test.go b/services/lfs/server_test.go deleted file mode 100644 index 875a99603e..0000000000 --- a/services/lfs/server_test.go +++ /dev/null @@ -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) }) - } -}