feat: enable auth to raw resources, release downloads, & attachments via authorized integrations (#12776)

A handful of routes, described in this PR as "mixed routes", are currently accessible by both web-based sessions and authenticated API users.  The goal of this PR is to allow access to these routes for Authorized Integrations as well, bringing them to full API compatibility (to my knowledge) with other authentication methods.  These routes are impacted:
- `/{username}/{repo}/raw/*`
- `/{username}/{repo}/archive/*`
- `/{username}/{repo}/releases/download/{vTag}/{fileName}`
- `/{username}/{repo}/attachments/{uuid}`
- `/attachments/{uuid}`

The major work in this PR was to refactoring the existing authentication methods so that "path based matching" that they were currently doing was no longer required, as I didn't want to introduce that into Authorized Integrations.  All the path based matching is removed in this PR, and authentication methods are enabled entirely by the middleware applied to their endpoints.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12776
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak 2026-05-29 02:11:43 +02:00 committed by Mathieu Fenniak
commit de5f38c4ea
16 changed files with 444 additions and 396 deletions

View file

@ -1,14 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package middleware
import (
"net/http"
"strings"
)
// IsAPIPath returns true if the specified URL is an API path
func IsAPIPath(req *http.Request) bool {
return strings.HasPrefix(req.URL.Path, "/api/")
}

View file

@ -193,9 +193,15 @@ func CommonRoutes() *web.Route {
verifyAuth(r, []auth.Method{
&auth_method.OAuth2{},
&auth_method.Basic{},
&auth_method.AccessToken{},
&auth_method.AccessToken{
PermitBasic: true,
PermitBearer: true,
},
&auth_method.ActionRuntimeToken{},
&auth_method.ActionTaskToken{},
&auth_method.ActionTaskToken{
PermitBasic: true,
PermitBearer: true,
},
&nuget.Auth{},
&conan.Auth{},
&chef.Auth{},
@ -842,9 +848,12 @@ func ContainerRoutes() *web.Route {
verifyContainerAuth(r, []auth.Method{
&auth_method.Basic{},
&auth_method.AccessToken{},
&auth_method.ActionRuntimeToken{},
&auth_method.ActionTaskToken{},
&auth_method.AccessToken{
PermitBasic: true,
},
&auth_method.ActionTaskToken{
PermitBasic: true,
},
&container.Auth{},
&auth_method.AuthorizedIntegration{
// `docker login` can't send a bearer token, so enable reading a token from the password field of

View file

@ -50,9 +50,15 @@ func buildAuthGroup() *auth_method.Group {
&auth_method.OAuth2{},
&auth_method.HTTPSign{},
&auth_method.Basic{}, // FIXME: this should be removed once we don't allow basic auth in API
&auth_method.AccessToken{},
&auth_method.AccessToken{
PermitBasic: true,
PermitBearer: true,
},
&auth_method.ActionRuntimeToken{},
&auth_method.ActionTaskToken{},
&auth_method.ActionTaskToken{
PermitBasic: true,
PermitBearer: true,
},
&auth_method.AuthorizedIntegration{},
)
if setting.Service.EnableReverseProxyAuthAPI {

View file

@ -105,24 +105,36 @@ 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()
if setting.Service.EnableReverseProxyAuth {
// reverseproxy should before Session, otherwise the header will be ignored if user has login
group.Add(&auth_method.ReverseProxy{
CreateSession: true,
})
}
group.Add(&auth_method.Session{})
return group
}
// 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:
// Authentication methods that are applied to "mixed" web URL routes. Mixed routes are those that are primarily
// designed for web browser access, but can also be accessed by API consumers.
func buildMixedAuthGroup() *auth_method.Group {
group := auth_method.NewGroup()
group.Add(&auth_method.OAuth2{})
group.Add(&auth_method.Basic{})
group.Add(&auth_method.AccessToken{})
group.Add(&auth_method.AccessToken{
PermitBasic: true,
PermitBearer: true,
})
group.Add(&auth_method.ActionRuntimeToken{})
group.Add(&auth_method.ActionTaskToken{})
group.Add(&auth_method.ActionTaskToken{
PermitBasic: true,
PermitBearer: true,
})
group.Add(&auth_method.AuthorizedIntegration{})
if setting.Service.EnableReverseProxyAuth {
group.Add(&auth_method.ReverseProxy{}) // reverseproxy should before Session, otherwise the header will be ignored if user has login
}
group.Add(&auth_method.Session{})
return group
}
@ -132,8 +144,17 @@ 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{})
group.Add(&auth_method.AccessToken{
PermitBasic: true,
// PermitBearer is left at default `false`. This behaviour is maintained from when one auth method performed
// all the "Basic ..." handling, and one performed all the "Bearer ..." handling, and access to LFS paths
// weren't permitted in the bearer codepath. There isn't a clear reason from Forgejo's perspective to deny
// usage in this case, it's just maintained because it previously worked that way.
})
group.Add(&auth_method.ActionTaskToken{
PermitBasic: true,
// PermitBearer is left at default `false` -- same explanation as above for AccessToken.
})
group.Add(&auth_method.AuthorizedIntegration{
// "Authorization: Basic ..." is easier to use for git operations, and already supported for other tokens, so it
// is enabled for Authorized Integrations as well:
@ -148,9 +169,15 @@ func buildGitAuthGroup() *auth_method.Group {
group := auth_method.NewGroup()
group.Add(&auth_method.OAuth2{})
group.Add(&auth_method.Basic{})
group.Add(&auth_method.AccessToken{})
group.Add(&auth_method.AccessToken{
PermitBasic: true,
PermitBearer: true,
})
group.Add(&auth_method.ActionRuntimeToken{})
group.Add(&auth_method.ActionTaskToken{})
group.Add(&auth_method.ActionTaskToken{
PermitBasic: true,
PermitBearer: true,
})
group.Add(&auth_method.AuthorizedIntegration{
// "Authorization: Basic ..." is easier to use for git operations, and already supported for other tokens, so it
// is enabled for Authorized Integrations as well:
@ -360,6 +387,11 @@ 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)
routes.Group("",
func() {
registerMixedRoutes(routes)
},
gzipMid, common.Sessioner(), context.Contexter(), webAuth(buildMixedAuthGroup()), goGet)
routes.Group("",
func() {
registerGitLFSRoutes(routes)
@ -369,6 +401,15 @@ func Routes() *web.Route {
registerGitRoutes(routes)
}, gzipMid, common.Sessioner(), context.Contexter(), webAuth(buildGitAuthGroup()), goGet)
// The only endpoint which can only be accessed with the OAuth2 authentication method is /userinfo, extracted here
// so that other auth methods can't be applied to it
routes.Methods(
"GET, POST, OPTIONS",
"/login/oauth/userinfo",
gzipMid, common.Sessioner(), context.Contexter(),
oauth2Enabled, optionsCorsHandler(), ignoreCSRF, webAuth(&auth_method.OAuth2{}),
auth.InfoOAuth)
routes.NotFound(
gzipMid, common.Sessioner(), context.Contexter(), webAuth(buildAuthGroup()),
// TODO: GetNotificationCount & GetActiveStopwatch really seem like things that could be folded into Contexter or as helper functions
@ -385,26 +426,39 @@ func Routes() *web.Route {
return routes
}
var ignoreCSRF = verifyAuthWithOptions(&common.VerifyOptions{DisableCSRF: true})
// registerRoutes register routes
func registerRoutes(m *web.Route) {
reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true})
reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true})
var (
ignoreCSRF = verifyAuthWithOptions(&common.VerifyOptions{DisableCSRF: true})
reqSignIn = verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true})
reqSignOut = verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true})
// TODO: rename them to "optSignIn", which means that the "sign-in" could be optional, depends on the VerifyOptions (RequireSignInView)
ignSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView})
ignExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView})
ignSignIn = verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView})
ignExploreSignIn = verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView})
validation.AddBindingRules()
reqRepoAdmin = context.RequireRepoAdmin()
reqRepoCodeWriter = context.RequireRepoWriter(unit.TypeCode)
canEnableEditor = context.CanEnableEditor()
reqRepoCodeReader = context.RequireRepoReader(unit.TypeCode)
reqRepoReleaseWriter = context.RequireRepoWriter(unit.TypeReleases)
reqRepoReleaseReader = context.RequireRepoReader(unit.TypeReleases)
reqRepoWikiWriter = context.RequireRepoWriter(unit.TypeWiki)
reqRepoIssueReader = context.RequireRepoReader(unit.TypeIssues)
reqRepoPullsReader = context.RequireRepoReader(unit.TypePullRequests)
reqRepoIssuesOrPullsWriter = context.RequireRepoWriterOr(unit.TypeIssues, unit.TypePullRequests)
reqRepoIssuesOrPullsReader = context.RequireRepoReaderOr(unit.TypeIssues, unit.TypePullRequests)
reqRepoProjectsReader = context.RequireRepoReader(unit.TypeProjects)
reqRepoProjectsWriter = context.RequireRepoWriter(unit.TypeProjects)
reqRepoActionsReader = context.RequireRepoReader(unit.TypeActions)
reqRepoActionsWriter = context.RequireRepoWriter(unit.TypeActions)
reqRepoDelegateActionTrust = context.RequireRepoDelegateActionTrust()
linkAccountEnabled := func(ctx *context.Context) {
linkAccountEnabled = func(ctx *context.Context) {
if !setting.Service.EnableOpenIDSignIn && !setting.Service.EnableOpenIDSignUp && !setting.OAuth2.Enabled {
ctx.Error(http.StatusForbidden)
return
}
}
requiredTwoFactor := func(ctx *context.Context) {
requiredTwoFactor = func(ctx *context.Context) {
if !ctx.Doer.MustHaveTwoFactor() {
return
}
@ -418,28 +472,28 @@ func registerRoutes(m *web.Route) {
ctx.Data["HideNavbarLinks"] = !hasTwoFactor
}
openIDSignInEnabled := func(ctx *context.Context) {
openIDSignInEnabled = func(ctx *context.Context) {
if !setting.Service.EnableOpenIDSignIn {
ctx.Error(http.StatusForbidden)
return
}
}
openIDSignUpEnabled := func(ctx *context.Context) {
openIDSignUpEnabled = func(ctx *context.Context) {
if !setting.Service.EnableOpenIDSignUp {
ctx.Error(http.StatusForbidden)
return
}
}
oauth2Enabled := func(ctx *context.Context) {
oauth2Enabled = func(ctx *context.Context) {
if !setting.OAuth2.Enabled {
ctx.Error(http.StatusForbidden)
return
}
}
reqMilestonesDashboardPageEnabled := func(ctx *context.Context) {
reqMilestonesDashboardPageEnabled = func(ctx *context.Context) {
if !setting.Service.ShowMilestonesDashboardPage {
ctx.Error(http.StatusForbidden)
return
@ -447,49 +501,42 @@ func registerRoutes(m *web.Route) {
}
// webhooksEnabled requires webhooks to be enabled by admin.
webhooksEnabled := func(ctx *context.Context) {
webhooksEnabled = func(ctx *context.Context) {
if setting.DisableWebhooks {
ctx.Error(http.StatusForbidden)
return
}
}
federationEnabled := func(ctx *context.Context) {
federationEnabled = func(ctx *context.Context) {
if !setting.Federation.Enabled {
ctx.Error(http.StatusNotFound)
return
}
}
dlSourceEnabled := func(ctx *context.Context) {
if setting.Repository.DisableDownloadSourceArchives {
ctx.Error(http.StatusNotFound)
return
}
}
sitemapEnabled := func(ctx *context.Context) {
sitemapEnabled = func(ctx *context.Context) {
if !setting.Other.EnableSitemap {
ctx.Error(http.StatusNotFound)
return
}
}
packagesEnabled := func(ctx *context.Context) {
packagesEnabled = func(ctx *context.Context) {
if !setting.Packages.Enabled {
ctx.Error(http.StatusForbidden)
return
}
}
feedEnabled := func(ctx *context.Context) {
feedEnabled = func(ctx *context.Context) {
if !setting.Other.EnableFeed {
ctx.Error(http.StatusNotFound)
return
}
}
reqUnitAccess := func(unitType unit.Type, accessMode perm.AccessMode, ignoreGlobal bool) func(ctx *context.Context) {
reqUnitAccess = func(unitType unit.Type, accessMode perm.AccessMode, ignoreGlobal bool) func(ctx *context.Context) {
return func(ctx *context.Context) {
// only check global disabled units when ignoreGlobal is false
if !ignoreGlobal && unitType.UnitGlobalDisabled() {
@ -510,6 +557,11 @@ func registerRoutes(m *web.Route) {
}
}
}
)
// registerRoutes register routes
func registerRoutes(m *web.Route) {
validation.AddBindingRules()
addSettingsVariablesRoutes := func() {
m.Group("/variables", func() {
@ -640,7 +692,6 @@ func registerRoutes(m *web.Route) {
}, reqSignIn)
m.Group("", func() {
m.Methods("GET, POST, OPTIONS", "/userinfo", auth.InfoOAuth)
m.Methods("POST, OPTIONS", "/access_token", web.Bind(forms.AccessTokenForm{}), auth.AccessTokenOAuth)
m.Methods("GET, OPTIONS", "/keys", auth.OIDCKeys)
m.Methods("POST, OPTIONS", "/introspect", web.Bind(forms.IntrospectTokenForm{}), auth.IntrospectOAuth)
@ -932,28 +983,10 @@ func registerRoutes(m *web.Route) {
m.Group("", func() {
m.Get("/{username}", user.UsernameSubRoute)
m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment)
}, ignSignIn)
m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), user.Action)
reqRepoAdmin := context.RequireRepoAdmin()
reqRepoCodeWriter := context.RequireRepoWriter(unit.TypeCode)
canEnableEditor := context.CanEnableEditor()
reqRepoCodeReader := context.RequireRepoReader(unit.TypeCode)
reqRepoReleaseWriter := context.RequireRepoWriter(unit.TypeReleases)
reqRepoReleaseReader := context.RequireRepoReader(unit.TypeReleases)
reqRepoWikiWriter := context.RequireRepoWriter(unit.TypeWiki)
reqRepoIssueReader := context.RequireRepoReader(unit.TypeIssues)
reqRepoPullsReader := context.RequireRepoReader(unit.TypePullRequests)
reqRepoIssuesOrPullsWriter := context.RequireRepoWriterOr(unit.TypeIssues, unit.TypePullRequests)
reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(unit.TypeIssues, unit.TypePullRequests)
reqRepoProjectsReader := context.RequireRepoReader(unit.TypeProjects)
reqRepoProjectsWriter := context.RequireRepoWriter(unit.TypeProjects)
reqRepoActionsReader := context.RequireRepoReader(unit.TypeActions)
reqRepoActionsWriter := context.RequireRepoWriter(unit.TypeActions)
reqRepoDelegateActionTrust := context.RequireRepoDelegateActionTrust()
reqPackageAccess := func(accessMode perm.AccessMode) func(ctx *context.Context) {
return func(ctx *context.Context) {
if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() {
@ -1478,7 +1511,6 @@ func registerRoutes(m *web.Route) {
}, ctxDataSet("EnableFeed", setting.Other.EnableFeed),
repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefTag, true))
m.Get("/releases/attachments/{uuid}", repo.MustBeNotEmpty, repo.GetAttachment)
m.Get("/releases/download/{vTag}/{fileName}", repo.MustBeNotEmpty, repo.RedirectDownload)
m.Group("/releases", func() {
m.Combo("/new", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)).
Get(repo.NewRelease).
@ -1493,11 +1525,6 @@ func registerRoutes(m *web.Route) {
}, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, repo.CommitInfoCache, context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo))
}, ignSignIn, context.RepoAssignment, context.UnitTypes(), reqRepoReleaseReader)
// to maintain compatibility with old attachments
m.Group("/{username}/{reponame}", func() {
m.Get("/attachments/{uuid}", repo.GetAttachment)
}, ignSignIn, context.RepoAssignment, context.UnitTypes())
m.Group("/{username}/{reponame}", func() {
m.Post("/topics", repo.TopicsPost)
}, context.RepoAssignment, context.RepoMustNotBeArchived(), reqRepoAdmin)
@ -1646,11 +1673,6 @@ func registerRoutes(m *web.Route) {
m.Get("/{period}", repo.ActivityAuthors)
}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypeCode))
m.Group("/archive", func() {
m.Get("/*", repo.Download)
m.Post("/*", repo.InitiateDownload)
}, repo.MustBeNotEmpty, dlSourceEnabled, reqRepoCodeReader)
m.Group("/branches", func() {
m.Get("/list", repo.GetBranchesList)
m.Get("", repo.Branches)
@ -1721,15 +1743,6 @@ func registerRoutes(m *web.Route) {
m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.SingleDownloadOrLFS)
}, repo.MustBeNotEmpty, reqRepoCodeReader)
m.Group("/raw", func() {
m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.SingleDownload)
m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.SingleDownload)
m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.SingleDownload)
m.Get("/blob/{sha}", context.RepoRefByType(context.RepoRefBlob), repo.DownloadByID)
// "/*" route is deprecated, and kept for backward compatibility
m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.SingleDownload)
}, repo.MustBeNotEmpty, reqRepoCodeReader)
m.Group("/render", func() {
m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.RenderFile)
m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.RenderFile)
@ -1870,6 +1883,44 @@ func registerGitRoutes(m *web.Route) {
})
}
func registerMixedRoutes(m *web.Route) {
dlSourceEnabled := func(ctx *context.Context) {
if setting.Repository.DisableDownloadSourceArchives {
ctx.Error(http.StatusNotFound)
return
}
}
m.Group("/{username}/{reponame}", func() {
m.Group("/raw", func() {
m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.SingleDownload)
m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.SingleDownload)
m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.SingleDownload)
m.Get("/blob/{sha}", context.RepoRefByType(context.RepoRefBlob), repo.DownloadByID)
// "/*" route is deprecated, and kept for backward compatibility
m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.SingleDownload)
}, repo.MustBeNotEmpty, reqRepoCodeReader)
m.Group("/archive", func() {
m.Get("/*", repo.Download)
m.Post("/*", repo.InitiateDownload)
}, repo.MustBeNotEmpty, dlSourceEnabled, reqRepoCodeReader)
}, ignSignIn, context.RepoAssignment, context.UnitTypes())
m.Group("/{username}/{reponame}", func() {
m.Get("/releases/download/{vTag}/{fileName}", repo.MustBeNotEmpty, repo.RedirectDownload)
}, ignSignIn, context.RepoAssignment, context.UnitTypes(), reqRepoReleaseReader)
// to maintain compatibility with old attachments
m.Group("/{username}/{reponame}", func() {
m.Get("/attachments/{uuid}", repo.GetAttachment)
}, ignSignIn, context.RepoAssignment, context.UnitTypes())
m.Group("", func() {
m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment)
}, ignSignIn)
}
func BindUpload() http.HandlerFunc {
return func(resp http.ResponseWriter, req *http.Request) {
theObj := new(forms.UploadRepoFileForm) // create a new form obj for every request but not use obj directly

View file

@ -11,24 +11,21 @@ import (
user_model "forgejo.org/models/user"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/web/middleware"
"forgejo.org/services/auth"
"forgejo.org/services/authz"
)
var _ auth.Method = &AccessToken{}
type AccessToken struct{}
type AccessToken struct {
// Permit the use of `Authorization: Basic ...` to include an access token.
PermitBasic bool
// Permit the use of `Authorization: Bearer ...`, `Authorization: Token ...`, and form-based tokens.
PermitBearer bool
}
func (a *AccessToken) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput {
// Authentication previously was performed in a single routine for `Authorization: Basic ...` and `Authorization:
// Bearer ...`, and both routines had separate URL exclusion lists onto which they wouldn't apply. That behaviour
// is maintained by cloning those conditions here and deciding whether to look at basic/bearer auth, or not. In the
// future this should be removed and migrated to route-specific middleware.
legacySkipBasic := !middleware.IsAPIPath(req) && !isContainerPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req)
legacySkipFormAndBearer := !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isAuthenticatedTokenRequest(req) && !isGitRawOrAttachPath(req) && !isArchivePath(req)
maybeAuthToken := a.getTokenFromRequest(req, legacySkipBasic, legacySkipFormAndBearer)
maybeAuthToken := a.getTokenFromRequest(req)
if !maybeAuthToken.Has() {
return &auth.AuthenticationNotAttempted{}
}
@ -60,8 +57,8 @@ func (a *AccessToken) Verify(req *http.Request, w http.ResponseWriter, _ auth.Se
return &auth.AuthenticationSuccess{Result: &accessTokenAuthenticationResult{user: u, scope: token.Scope, reducer: reducer}}
}
func (a *AccessToken) getTokenFromRequest(req *http.Request, skipBasic, skipFormAndBearer bool) optional.Option[string] {
if !skipFormAndBearer {
func (a *AccessToken) getTokenFromRequest(req *http.Request) optional.Option[string] {
if a.PermitBearer {
if has, token := tokenFromForm(req).Get(); has {
return optional.Some(token)
}
@ -69,7 +66,7 @@ func (a *AccessToken) getTokenFromRequest(req *http.Request, skipBasic, skipForm
return optional.Some(token)
}
}
if !skipBasic {
if a.PermitBasic {
if has, token := tokenFromAuthorizationBasic(req).Get(); has {
return optional.Some(token)
}

View file

@ -11,7 +11,6 @@ import (
actions_model "forgejo.org/models/actions"
user_model "forgejo.org/models/user"
"forgejo.org/modules/optional"
"forgejo.org/modules/web/middleware"
"forgejo.org/services/actions"
"forgejo.org/services/auth"
)
@ -21,11 +20,6 @@ var _ auth.Method = &ActionRuntimeToken{}
type ActionRuntimeToken struct{}
func (a *ActionRuntimeToken) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput {
// In the future this should be removed and migrated to route-specific middleware:
if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isAuthenticatedTokenRequest(req) && !isGitRawOrAttachPath(req) && !isArchivePath(req) {
return &auth.AuthenticationNotAttempted{}
}
maybeAuthToken := a.getTokenFromRequest(req)
if !maybeAuthToken.Has() {
return &auth.AuthenticationNotAttempted{}

View file

@ -13,23 +13,20 @@ import (
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/util"
"forgejo.org/modules/web/middleware"
"forgejo.org/services/auth"
)
var _ auth.Method = &ActionTaskToken{}
type ActionTaskToken struct{}
type ActionTaskToken struct {
// Permit the use of `Authorization: Basic ...` to include an access token.
PermitBasic bool
// Permit the use of `Authorization: Bearer ...`, `Authorization: Token ...`, and form-based tokens.
PermitBearer bool
}
func (a *ActionTaskToken) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput {
// Authentication previously was performed in a single routine for `Authorization: Basic ...` and `Authorization:
// Bearer ...`, and both routines had separate URL exclusion lists onto which they wouldn't apply. That behaviour
// is maintained by cloning those conditions here and deciding whether to look at basic/bearer auth, or not. In the
// future this should be removed and migrated to route-specific middleware.
legacySkipBasic := !middleware.IsAPIPath(req) && !isContainerPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req)
legacySkipFormAndBearer := !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isAuthenticatedTokenRequest(req) && !isGitRawOrAttachPath(req) && !isArchivePath(req)
maybeAuthToken := a.getTokenFromRequest(req, legacySkipBasic, legacySkipFormAndBearer)
maybeAuthToken := a.getTokenFromRequest(req)
if !maybeAuthToken.Has() {
return &auth.AuthenticationNotAttempted{}
}
@ -49,8 +46,8 @@ func (a *ActionTaskToken) Verify(req *http.Request, w http.ResponseWriter, _ aut
return &auth.AuthenticationSuccess{Result: &actionsTaskTokenAuthenticationResult{user: user_model.NewActionsUser(), taskID: task.ID}}
}
func (a *ActionTaskToken) getTokenFromRequest(req *http.Request, skipBasic, skipFormAndBearer bool) optional.Option[string] {
if !skipFormAndBearer {
func (a *ActionTaskToken) getTokenFromRequest(req *http.Request) optional.Option[string] {
if a.PermitBearer {
if has, token := tokenFromForm(req).Get(); has {
return optional.Some(token)
}
@ -58,7 +55,7 @@ func (a *ActionTaskToken) getTokenFromRequest(req *http.Request, skipBasic, skip
return optional.Some(token)
}
}
if !skipBasic {
if a.PermitBasic {
if has, token := tokenFromAuthorizationBasic(req).Get(); has {
return optional.Some(token)
}

View file

@ -7,15 +7,12 @@ package method
import (
"fmt"
"net/http"
"regexp"
"strings"
user_model "forgejo.org/models/user"
"forgejo.org/modules/auth/webauthn"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/session"
"forgejo.org/modules/setting"
"forgejo.org/modules/web/middleware"
"forgejo.org/services/auth"
user_service "forgejo.org/services/user"
@ -27,42 +24,6 @@ func Init() {
webauthn.Init()
}
// isAttachmentDownload check if request is a file download (GET) with URL to an attachment
func isAttachmentDownload(req *http.Request) bool {
return strings.HasPrefix(req.URL.Path, "/attachments/") && req.Method == "GET"
}
// isContainerPath checks if the request targets the container endpoint
func isContainerPath(req *http.Request) bool {
// Go's URL omits trailing slashes from `Path`. That means that `/v2/`, the top-level endpoint, appears as `/v2`.
// strings.HasPrefix(req.URL.Path, "/v2") would be inappropriate because it would match paths like `/v2-abcd`, too.
return req.URL.Path == "/v2" || strings.HasPrefix(req.URL.Path, "/v2/")
}
var (
gitRawOrAttachPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/(?:(?:git-(?:(?:upload)|(?:receive))-pack$)|(?:info/refs$)|(?:HEAD$)|(?:objects/)|(?:raw/)|(?:releases/download/)|(?:attachments/))`)
lfsPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/info/lfs/`)
archivePathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/archive/`)
)
func isGitRawOrAttachPath(req *http.Request) bool {
return gitRawOrAttachPathRe.MatchString(req.URL.Path)
}
func isGitRawOrAttachOrLFSPath(req *http.Request) bool {
if isGitRawOrAttachPath(req) {
return true
}
if setting.LFS.StartServer {
return lfsPathRe.MatchString(req.URL.Path)
}
return false
}
func isArchivePath(req *http.Request) bool {
return archivePathRe.MatchString(req.URL.Path)
}
// handleSignIn clears existing session variables and stores new ones for the specified user object
func handleSignIn(resp http.ResponseWriter, req *http.Request, sess auth.SessionStore, user *user_model.User) {
// We need to regenerate the session...

View file

@ -1,198 +0,0 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package method
import (
"net/http"
"net/url"
"testing"
"forgejo.org/modules/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_isGitRawOrLFSPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{
"/owner/repo/git-upload-pack",
true,
},
{
"/owner/repo/git-receive-pack",
true,
},
{
"/owner/repo/info/refs",
true,
},
{
"/owner/repo/HEAD",
true,
},
{
"/owner/repo/objects/info/alternates",
true,
},
{
"/owner/repo/objects/info/http-alternates",
true,
},
{
"/owner/repo/objects/info/packs",
true,
},
{
"/owner/repo/objects/info/blahahsdhsdkla",
true,
},
{
"/owner/repo/objects/01/23456789abcdef0123456789abcdef01234567",
true,
},
{
"/owner/repo/objects/pack/pack-123456789012345678921234567893124567894.pack",
true,
},
{
"/owner/repo/objects/pack/pack-0123456789abcdef0123456789abcdef0123456.idx",
true,
},
{
"/owner/repo/raw/branch/foo/fanaso",
true,
},
{
"/owner/repo/stars",
false,
},
{
"/notowner",
false,
},
{
"/owner/repo",
false,
},
{
"/owner/repo/commit/123456789012345678921234567893124567894",
false,
},
{
"/owner/repo/releases/download/tag/repo.tar.gz",
true,
},
{
"/owner/repo/attachments/6d92a9ee-5d8b-4993-97c9-6181bdaa8955",
true,
},
}
lfsTests := []string{
"/owner/repo/info/lfs/",
"/owner/repo/info/lfs/objects/batch",
"/owner/repo/info/lfs/objects/oid/filename",
"/owner/repo/info/lfs/objects/oid",
"/owner/repo/info/lfs/objects",
"/owner/repo/info/lfs/verify",
"/owner/repo/info/lfs/locks",
"/owner/repo/info/lfs/locks/verify",
"/owner/repo/info/lfs/locks/123/unlock",
}
origLFSStartServer := setting.LFS.StartServer
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
req, _ := http.NewRequest("POST", "http://localhost"+tt.path, nil)
setting.LFS.StartServer = false
if got := isGitRawOrAttachOrLFSPath(req); got != tt.want {
t.Errorf("isGitOrLFSPath() = %v, want %v", got, tt.want)
}
setting.LFS.StartServer = true
if got := isGitRawOrAttachOrLFSPath(req); got != tt.want {
t.Errorf("isGitOrLFSPath() = %v, want %v", got, tt.want)
}
})
}
for _, tt := range lfsTests {
t.Run(tt, func(t *testing.T) {
req, _ := http.NewRequest("POST", tt, nil)
setting.LFS.StartServer = false
if got := isGitRawOrAttachOrLFSPath(req); got != setting.LFS.StartServer {
t.Errorf("isGitOrLFSPath(%q) = %v, want %v, %v", tt, got, setting.LFS.StartServer, gitRawOrAttachPathRe.MatchString(tt))
}
setting.LFS.StartServer = true
if got := isGitRawOrAttachOrLFSPath(req); got != setting.LFS.StartServer {
t.Errorf("isGitOrLFSPath(%q) = %v, want %v", tt, got, setting.LFS.StartServer)
}
})
}
setting.LFS.StartServer = origLFSStartServer
}
func TestAuth_isContainerPath(t *testing.T) {
testCases := []struct {
name string
input string
isContainerPath bool
}{
{
name: "without trailing slash",
input: "https://example.com/v2",
isContainerPath: true,
},
{
name: "with trailing slash",
input: "https://example.com/v2/",
isContainerPath: true,
},
{
name: "with additional path components",
input: "https://example.com/v2/example/blobs/uploads/",
isContainerPath: true,
},
{
name: "without v2",
input: "https://example.com/",
isContainerPath: false,
},
{
name: "v2 not at the beginning",
input: "https://example.com/something/v2/",
isContainerPath: false,
},
{
name: "v2 with prefix",
input: "https://example.com/abcd-v2/",
isContainerPath: false,
},
{
name: "v2 with suffix",
input: "https://example.com/v2-abcd/",
isContainerPath: false,
},
{
name: "v1",
input: "https://example.com/v1/",
isContainerPath: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
inputURL, err := url.Parse(testCase.input)
require.NoError(t, err)
request := http.Request{URL: inputURL}
assert.Equal(t, testCase.isContainerPath, isContainerPath(&request))
})
}
}

View file

@ -16,7 +16,6 @@ import (
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/util"
"forgejo.org/modules/web/middleware"
"forgejo.org/services/auth"
"forgejo.org/services/auth/source/db"
)
@ -34,11 +33,6 @@ type Basic struct{}
// Verify extracts and validates Basic data (username and password/token) from the "Authorization" header of the request
// and returns the corresponding user object for that name/token on successful validation.
func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput {
// Basic authentication should only fire on API, Download or on Git or LFSPaths
if !middleware.IsAPIPath(req) && !isContainerPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req) {
return &auth.AuthenticationNotAttempted{}
}
baHead := req.Header.Get("Authorization")
if len(baHead) == 0 {
return &auth.AuthenticationNotAttempted{}

View file

@ -17,7 +17,6 @@ import (
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/modules/web/middleware"
"forgejo.org/services/auth"
"forgejo.org/services/auth/source/oauth2"
)
@ -74,11 +73,6 @@ func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, _ auth.Session
if !setting.OAuth2.Enabled {
return &auth.AuthenticationNotAttempted{}
}
// These paths are not API paths, but we still want to check for tokens because they maybe in the API returned URLs
if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isAuthenticatedTokenRequest(req) &&
!isGitRawOrAttachPath(req) && !isArchivePath(req) {
return &auth.AuthenticationNotAttempted{}
}
maybeAuthToken := o.getTokenFromRequest(req)
if !maybeAuthToken.Has() {
@ -145,7 +139,3 @@ func (*OAuth2) getTokenFromRequest(req *http.Request) optional.Option[string] {
}
return optional.None[string]()
}
func isAuthenticatedTokenRequest(req *http.Request) bool {
return req.URL.Path == "/login/oauth/userinfo"
}

View file

@ -15,7 +15,6 @@ import (
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/modules/util"
"forgejo.org/modules/web/middleware"
"forgejo.org/services/auth"
gouuid "github.com/google/uuid"
@ -34,7 +33,10 @@ const ReverseProxyMethodName = "reverse_proxy"
// On successful authentication the proxy is expected to populate the username in the
// "setting.ReverseProxyAuthUser" header. Optionally it can also populate the email of the
// user in the "setting.ReverseProxyAuthEmail" header.
type ReverseProxy struct{}
type ReverseProxy struct {
// If true, create a session once a user authenticates.
CreateSession bool
}
// getUserName extracts the username from the "setting.ReverseProxyAuthUser" header
func (r *ReverseProxy) getUserName(req *http.Request) string {
@ -120,8 +122,7 @@ func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, sess aut
}
}
// Make sure requests to API paths, attachment downloads, git and LFS do not create a new session
if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req) {
if r.CreateSession {
if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) {
handleSignIn(w, req, sess, user)
}

View file

@ -358,3 +358,76 @@ func TestGetAttachmentViaAPITokens(t *testing.T) {
})
})
}
func TestGetAttachmentViaAuthorizedIntegration(t *testing.T) {
defer unittest.OverrideFixtures("tests/integration/fixtures/TestGetAttachmentViaAPITokens")()
defer tests.PrepareTestEnv(t)()
// Create attachment data for an attachment added by this test's fixture.
_, err := storage.Attachments.Save(repo_model.AttachmentRelativePath("d962b49e-e32a-4b72-922d-33b551b629e2"), strings.NewReader("hello universe"), -1)
require.NoError(t, err)
// Enable Issues unit on repo 16, one of our test targets.
repo_service.UpdateRepositoryUnits(t.Context(),
unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}),
[]repo_model.RepoUnit{{
RepoID: 16,
Type: unit_model.TypeIssues,
}}, nil)
// Clone of the "all access token" tests from TestGetAttachmentViaAPITokens -- not all test conditions are repeated
// as there's no unique code in attachment code paths for authorized integrations other than the authentication
// method. Scopes and repo-specific reducers are common to both implementations.
ait := newAITester(t, func(ai *auth_model.AuthorizedIntegration) {
ai.Scope = auth_model.AccessTokenScopeReadIssue
})
defer ait.close()
token := ait.signedJWT()
t.Run("attachments", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("allowed public repo1", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "hello world", resp.Body.String())
})
t.Run("allowed private repo2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "hello world", resp.Body.String())
})
t.Run("allowed private repo16", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/attachments/d962b49e-e32a-4b72-922d-33b551b629e2").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "hello universe", resp.Body.String())
})
})
t.Run("user-repo-attachments", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("allowed public repo1", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "hello world", resp.Body.String())
})
t.Run("allowed private repo2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo2/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "hello world", resp.Body.String())
})
t.Run("allowed private repo16", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo16/attachments/d962b49e-e32a-4b72-922d-33b551b629e2").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "hello universe", resp.Body.String())
})
})
}

View file

@ -198,3 +198,34 @@ func TestDownloadAccessViaAPITokens(t *testing.T) {
})
})
}
func TestDownloadAccessViaAuthorizedIntegration(t *testing.T) {
defer tests.PrepareTestEnv(t)()
ait := newAITester(t, func(ai *auth_model.AuthorizedIntegration) {
ai.Scope = auth_model.AccessTokenScopeReadRepository
})
defer ait.close()
token := ait.signedJWT()
// Clone of the "all access token" tests from TestDownloadAccessViaAPITokens -- not all test conditions are repeated
// as there's no unique code in `/raw` code paths for authorized integrations other than the authentication method.
// Scopes and repo-specific reducers are common to both implementations.
t.Run("allowed public repo1", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/raw/blob/4b4851ad51df6a7d9f25c979345979eaeb5b349f").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String())
})
t.Run("allowed private repo2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo2/raw/blob/1032bbf17fbc0d9c95bb5418dabe8f8c99278700").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "tree ba1aed4e2ea2443d76cec241b96be4ec990852ec\nparent 205ac761f3326a7ebe416e8673760016450b5cec\nauthor Jimmy Praet <jimmy.praet@telenet.be> 1624996449 +0200\ncommitter Jimmy Praet <jimmy.praet@telenet.be> 1624996449 +0200\n\nAdd test.xml\n", resp.Body.String())
})
t.Run("allowed private repo16", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo16/raw/blob/69554a64c1e6030f051e5c3f94bfbd773cd6a324").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "tree 24f83a471f77579fea57bac7255d6e64e70fce1c\nparent 27566bd5738fc8b4e3fef3c5e72cce608537bd95\nauthor User2 <user2@example.com> 1502042309 +0200\ncommitter User2 <user2@example.com> 1502042309 +0200\n\nnot signed commit\n", resp.Body.String())
})
}

View file

@ -561,18 +561,21 @@ func TestQuotaGitLfsEnforcement(t *testing.T) {
// Uploading to our repo => 413
env.As(t, env.Users.Limited).
With(Context{Repo: env.Users.Limited.Repo}).
AuthBasic().
PushLFSObject().
ExpectStatus(http.StatusRequestEntityTooLarge)
// Uploading to the limited org repo => 413
env.As(t, env.Users.Limited).
With(Context{Repo: env.Orgs.Limited.Repo}).
AuthBasic().
PushLFSObject().
ExpectStatus(http.StatusRequestEntityTooLarge)
// Uploading to the unlimited org repo => 200
env.As(t, env.Users.Limited).
With(Context{Repo: env.Orgs.Unlimited.Repo}).
AuthBasic().
PushLFSObject().
ExpectStatus(http.StatusOK)
})
@ -581,18 +584,21 @@ func TestQuotaGitLfsEnforcement(t *testing.T) {
// Uploading to our repo => 413
env.As(t, env.Users.Limited).
With(Context{Repo: env.Users.Limited.Repo}).
AuthBasic().
BatchPushLFSObject().
ExpectStatus(http.StatusRequestEntityTooLarge)
// Uploading to the limited org repo => 413
env.As(t, env.Users.Limited).
With(Context{Repo: env.Orgs.Limited.Repo}).
AuthBasic().
BatchPushLFSObject().
ExpectStatus(http.StatusRequestEntityTooLarge)
// Uploading to the unlimited org repo => 200
env.As(t, env.Users.Limited).
With(Context{Repo: env.Orgs.Unlimited.Repo}).
AuthBasic().
BatchPushLFSObject().
ExpectStatus(http.StatusOK)
})
@ -693,8 +699,9 @@ type quotaWebEnvAsContext struct {
gitPath string
request *RequestWrapper
response *httptest.ResponseRecorder
request *RequestWrapper
response *httptest.ResponseRecorder
authBasic bool
}
type Context struct {
@ -726,10 +733,20 @@ func (ctx *quotaWebEnvAsContext) VisitRepoPage(page string) *quotaWebEnvAsContex
return ctx.VisitPage(ctx.Repo.Link() + page)
}
func (ctx *quotaWebEnvAsContext) AuthBasic() *quotaWebEnvAsContext {
ctx.t.Helper()
ctx.authBasic = true
return ctx
}
func (ctx *quotaWebEnvAsContext) ExpectStatus(status int) *quotaWebEnvAsContext {
ctx.t.Helper()
ctx.response = ctx.Doer.Session.MakeRequest(ctx.t, ctx.request, status)
if ctx.authBasic {
ctx.response = MakeRequest(ctx.t, ctx.request.AddBasicAuth(ctx.Doer.User.Name), status)
} else {
ctx.response = ctx.Doer.Session.MakeRequest(ctx.t, ctx.request, status)
}
return ctx
}

View file

@ -6,6 +6,7 @@ package integration
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"io"
@ -13,6 +14,7 @@ import (
"net/url"
"testing"
auth_model "forgejo.org/models/auth"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
@ -89,3 +91,140 @@ func TestRepoDownloadArchiveSubdir(t *testing.T) {
})
})
}
// Access under `/{username}/{repo}/archive/*` is permitted for API tokens. Those API tokens then need to have the
// read:repository and the correct resource scopes to permit access, though.
func TestRepoDownloadArchiveViaAPITokens(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("no read:repository scope", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2")
allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadMisc)
t.Run("denied public repo1", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip").AddTokenAuth(allToken)
MakeRequest(t, req, http.StatusForbidden)
})
t.Run("denied private repo2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo2/archive/master.zip").AddTokenAuth(allToken)
MakeRequest(t, req, http.StatusForbidden)
})
// repo16 is a second repo used in fine-grain testing below, so we include it in other tests as a baseline
t.Run("denied private repo16", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo16/archive/master.zip").AddTokenAuth(allToken)
MakeRequest(t, req, http.StatusForbidden)
})
})
t.Run("all access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2")
allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
t.Run("allowed public repo1", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip").AddTokenAuth(allToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.True(t, bytes.HasPrefix(resp.Body.Bytes(), []byte("PK")), "response body missing prefix PK")
})
t.Run("allowed private repo2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo2/archive/master.zip").AddTokenAuth(allToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.True(t, bytes.HasPrefix(resp.Body.Bytes(), []byte("PK")), "response body missing prefix PK")
})
// repo16 is a second repo used in fine-grain testing below, so we include it in other tests as a baseline
t.Run("allowed private repo16", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo16/archive/master.zip").AddTokenAuth(allToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.True(t, bytes.HasPrefix(resp.Body.Bytes(), []byte("PK")), "response body missing prefix PK")
})
})
t.Run("public-only access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2")
publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadRepository)
t.Run("allowed public repo1", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip").AddTokenAuth(publicOnlyToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.True(t, bytes.HasPrefix(resp.Body.Bytes(), []byte("PK")), "response body missing prefix PK")
})
t.Run("denied private repo2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo2/archive/master.zip").AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("denied private repo16", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo16/archive/master.zip").AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusNotFound)
})
})
t.Run("specific repo access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo2OnlyToken := createFineGrainedRepoAccessToken(t, "user2",
[]auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadRepository},
[]int64{2},
)
t.Run("allowed public repo1", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip").AddTokenAuth(repo2OnlyToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.True(t, bytes.HasPrefix(resp.Body.Bytes(), []byte("PK")), "response body missing prefix PK")
})
t.Run("allowed inside fine-grain repo2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo2/archive/master.zip").AddTokenAuth(repo2OnlyToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.True(t, bytes.HasPrefix(resp.Body.Bytes(), []byte("PK")), "response body missing prefix PK")
})
t.Run("denied private outside fine-grain repo16", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo16/archive/master.zip").AddTokenAuth(repo2OnlyToken)
MakeRequest(t, req, http.StatusNotFound)
})
})
}
func TestRepoDownloadArchiveViaAuthorizedIntegration(t *testing.T) {
defer tests.PrepareTestEnv(t)()
ait := newAITester(t, func(ai *auth_model.AuthorizedIntegration) {
ai.Scope = auth_model.AccessTokenScopeReadRepository
})
defer ait.close()
token := ait.signedJWT()
// Clone of the "all access token" tests from TestRepoDownloadArchiveViaAPITokens -- not all test conditions are
// repeated as there's no unique code in archive code paths for authorized integrations other than the
// authentication method. Scopes and repo-specific reducers are common to both implementations.
t.Run("allowed public repo1", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.True(t, bytes.HasPrefix(resp.Body.Bytes(), []byte("PK")), "response body missing prefix PK")
})
t.Run("allowed private repo2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo2/archive/master.zip").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.True(t, bytes.HasPrefix(resp.Body.Bytes(), []byte("PK")), "response body missing prefix PK")
})
t.Run("allowed private repo16", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo16/archive/master.zip").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.True(t, bytes.HasPrefix(resp.Body.Bytes(), []byte("PK")), "response body missing prefix PK")
})
}