diff --git a/go.mod b/go.mod index 6007181a63..9cf9183b8e 100644 --- a/go.mod +++ b/go.mod @@ -71,7 +71,7 @@ require ( github.com/lib/pq v1.10.9 github.com/markbates/goth v1.80.0 github.com/mattn/go-isatty v0.0.20 - github.com/mattn/go-sqlite3 v1.14.28 + github.com/mattn/go-sqlite3 v1.14.29 github.com/meilisearch/meilisearch-go v0.31.0 github.com/mholt/archiver/v3 v3.5.1 github.com/microcosm-cc/bluemonday v1.0.27 @@ -244,7 +244,7 @@ require ( replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1 -replace github.com/nektos/act => code.forgejo.org/forgejo/act v1.32.0 +replace github.com/nektos/act => code.forgejo.org/forgejo/act v1.33.0 replace github.com/mholt/archiver/v3 => code.forgejo.org/forgejo/archiver/v3 v3.5.1 diff --git a/go.sum b/go.sum index 7725cfd6d0..1367fc8d10 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ code.forgejo.org/f3/gof3/v3 v3.11.0 h1:f/xToKwqTgxG6PYxvewywjDQyCcyHEEJ6sZqUitFs code.forgejo.org/f3/gof3/v3 v3.11.0/go.mod h1:4FaRUNSQGBiD1M0DuB0yNv+Z2wMtlOeckgygHSSq4KQ= code.forgejo.org/forgejo-contrib/go-libravatar v0.0.0-20191008002943-06d1c002b251 h1:HTZl3CBk3ABNYtFI6TPLvJgGKFIhKT5CBk0sbOtkDKU= code.forgejo.org/forgejo-contrib/go-libravatar v0.0.0-20191008002943-06d1c002b251/go.mod h1:PphB88CPbx601QrWPMZATeorACeVmQlyv3u+uUMbSaM= -code.forgejo.org/forgejo/act v1.32.0 h1:hns2WvrJs6qWCmvzoSllNGNzSvcDMcSvJvVtQj3FaQc= -code.forgejo.org/forgejo/act v1.32.0/go.mod h1:WkmxVBteC4zoyQGYp8ZFZY7Xb+jat+b7ChvqW6TxqF8= +code.forgejo.org/forgejo/act v1.33.0 h1:ayQTXkpk+Vj5/yQMNZagA0xpQgGVeSbcrPXcIS3K1kY= +code.forgejo.org/forgejo/act v1.33.0/go.mod h1:WkmxVBteC4zoyQGYp8ZFZY7Xb+jat+b7ChvqW6TxqF8= code.forgejo.org/forgejo/archiver/v3 v3.5.1 h1:UmmbA7D5550uf71SQjarmrn6yKwOGxtEjb3jaYYtmSE= code.forgejo.org/forgejo/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= code.forgejo.org/forgejo/go-rpmutils v1.0.0 h1:RZGGeKt70p/WaIEL97pyT6uiiEIoN8/aLmS5Z6WmX0M= @@ -395,8 +395,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= -github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= +github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/meilisearch/meilisearch-go v0.31.0 h1:yZRhY1qJqdH8h6GFZALGtkDLyj8f9v5aJpsNMyrUmnY= github.com/meilisearch/meilisearch-go v0.31.0/go.mod h1:aNtyuwurDg/ggxQIcKqWH6G9g2ptc8GyY7PLY4zMn/g= github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= diff --git a/modules/setting/security.go b/modules/setting/security.go index c38d8dae79..f3480d1056 100644 --- a/modules/setting/security.go +++ b/modules/setting/security.go @@ -35,6 +35,7 @@ var ( PasswordHashAlgo string PasswordCheckPwn bool SuccessfulTokensCacheSize int + DisableQueryAuthToken bool CSRFCookieName = "_csrf" CSRFCookieHTTPOnly = true ) @@ -159,4 +160,14 @@ func loadSecurityFrom(rootCfg ConfigProvider) { PasswordComplexity = append(PasswordComplexity, name) } } + + sectionHasDisableQueryAuthToken := sec.HasKey("DISABLE_QUERY_AUTH_TOKEN") + + // TODO: default value should be true in future releases + DisableQueryAuthToken = sec.Key("DISABLE_QUERY_AUTH_TOKEN").MustBool(false) + + // warn if the setting is set to false explicitly + if sectionHasDisableQueryAuthToken && !DisableQueryAuthToken { + log.Warn("Enabling Query API Auth tokens is not recommended. DISABLE_QUERY_AUTH_TOKEN will default to true in gitea 1.23 and will be removed in gitea 1.24.") + } } diff --git a/routers/api/shared/middleware.go b/routers/api/shared/middleware.go index 7d537f1ef9..f56acbe1bf 100644 --- a/routers/api/shared/middleware.go +++ b/routers/api/shared/middleware.go @@ -30,6 +30,7 @@ func Middlewares() (stack []any) { return append(stack, context.APIContexter(), + checkDeprecatedAuthMethods, // Get user from session if logged in. apiAuth(buildAuthGroup()), verifyAuthWithOptions(&common.VerifyOptions{ @@ -126,6 +127,13 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC } } +// check for and warn against deprecated authentication options +func checkDeprecatedAuthMethods(ctx *context.APIContext) { + if ctx.FormString("token") != "" || ctx.FormString("access_token") != "" { + ctx.Resp.Header().Set("Warning", "token and access_token API authentication is deprecated and will be removed in gitea 1.23. Please use AuthorizationHeaderToken instead. Existing queries will continue to work but without authorization.") + } +} + func securityHeaders() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 8aa67b3b0a..de54f21d94 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -436,26 +436,6 @@ func SearchUsers(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - sort := ctx.FormString("sort") - var orderBy db.SearchOrderBy - - switch sort { - case "oldest": - orderBy = db.SearchOrderByOldest - case "newest": - orderBy = db.SearchOrderByNewest - case "alphabetically": - orderBy = db.SearchOrderByAlphabetically - case "reversealphabetically": - orderBy = db.SearchOrderByAlphabeticallyReverse - case "recentupdate": - orderBy = db.SearchOrderByRecentUpdated - case "leastupdate": - orderBy = db.SearchOrderByLeastUpdated - default: - orderBy = db.SearchOrderByAlphabetically - } - intSource, err := strconv.ParseInt(ctx.FormString("source_id"), 10, 64) var sourceID optional.Option[int64] if ctx.FormString("source_id") == "" || err != nil { @@ -469,7 +449,7 @@ func SearchUsers(ctx *context.APIContext) { Type: user_model.UserTypeIndividual, LoginName: ctx.FormTrim("login_name"), SourceID: sourceID, - OrderBy: orderBy, + OrderBy: utils.GetDbSearchOrder(ctx), ListOptions: listOptions, }) if err != nil { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 7437f68b5f..2f806ba35d 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -22,6 +22,8 @@ // // Security: // - BasicAuth : +// - Token : +// - AccessToken : // - AuthorizationHeaderToken : // - SudoParam : // - SudoHeader : @@ -30,6 +32,16 @@ // SecurityDefinitions: // BasicAuth: // type: basic +// Token: +// type: apiKey +// name: token +// in: query +// description: This authentication option is deprecated for removal in Forgejo v13.0.0. Please use AuthorizationHeaderToken instead. +// AccessToken: +// type: apiKey +// name: access_token +// in: query +// description: This authentication option is deprecated for removal in Forgejo v13.0.0. Please use AuthorizationHeaderToken instead. // AuthorizationHeaderToken: // type: apiKey // name: Authorization diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 5bdd56c892..19f7440047 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -33,6 +33,11 @@ func Search(ctx *context.APIContext) { // description: ID of the user to search for // type: integer // format: int64 + // - name: sort + // in: query + // description: sort order of results + // type: string + // enum: [oldest, newest, alphabetically, reversealphabetically, recentupdate, leastupdate] // - name: page // in: query // description: page number of results to return (1-based) @@ -81,6 +86,7 @@ func Search(ctx *context.APIContext) { SearchByEmail: true, Visible: visible, ListOptions: listOptions, + OrderBy: utils.GetDbSearchOrder(ctx), }) if err != nil { ctx.JSON(http.StatusInternalServerError, map[string]any{ diff --git a/routers/api/v1/utils/db_search_order.go b/routers/api/v1/utils/db_search_order.go new file mode 100644 index 0000000000..f089ba5f16 --- /dev/null +++ b/routers/api/v1/utils/db_search_order.go @@ -0,0 +1,28 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "forgejo.org/models/db" + "forgejo.org/services/context" +) + +func GetDbSearchOrder(ctx *context.APIContext) db.SearchOrderBy { + switch ctx.FormString("sort") { + case "oldest": + return db.SearchOrderByOldest + case "newest": + return db.SearchOrderByNewest + case "alphabetically": + return db.SearchOrderByAlphabetically + case "reversealphabetically": + return db.SearchOrderByAlphabeticallyReverse + case "recentupdate": + return db.SearchOrderByRecentUpdated + case "leastupdate": + return db.SearchOrderByLeastUpdated + default: + return db.SearchOrderByAlphabetically + } +} diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 4fdd15d7ec..fa13c20a7f 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -122,6 +122,18 @@ func (o *OAuth2) Name() string { // representing whether the token exists or not func parseToken(req *http.Request) (string, bool) { _ = req.ParseForm() + if !setting.DisableQueryAuthToken { + // Check token. + if token := req.Form.Get("token"); token != "" { + return token, true + } + // Check access token. + if token := req.Form.Get("access_token"); token != "" { + return token, true + } + } else if req.Form.Get("token") != "" || req.Form.Get("access_token") != "" { + log.Warn("API token sent in query string but DISABLE_QUERY_AUTH_TOKEN=true") + } // check header token if auHead := req.Header.Get("Authorization"); auHead != "" { diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index bba6825b72..e03f5d57c1 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -20284,6 +20284,20 @@ "name": "uid", "in": "query" }, + { + "enum": [ + "oldest", + "newest", + "alphabetically", + "reversealphabetically", + "recentupdate", + "leastupdate" + ], + "type": "string", + "description": "sort order of results", + "name": "sort", + "in": "query" + }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -30084,6 +30098,12 @@ } }, "securityDefinitions": { + "AccessToken": { + "description": "This authentication option is deprecated for removal in Forgejo v13.0.0. Please use AuthorizationHeaderToken instead.", + "type": "apiKey", + "name": "access_token", + "in": "query" + }, "AuthorizationHeaderToken": { "description": "API tokens must be prepended with \"token\" followed by a space.", "type": "apiKey", @@ -30110,12 +30130,24 @@ "type": "apiKey", "name": "X-FORGEJO-OTP", "in": "header" + }, + "Token": { + "description": "This authentication option is deprecated for removal in Forgejo v13.0.0. Please use AuthorizationHeaderToken instead.", + "type": "apiKey", + "name": "token", + "in": "query" } }, "security": [ { "BasicAuth": [] }, + { + "Token": [] + }, + { + "AccessToken": [] + }, { "AuthorizationHeaderToken": [] }, diff --git a/tests/e2e/shared/forms.ts b/tests/e2e/shared/forms.ts index adb5b6e3cb..862a243600 100644 --- a/tests/e2e/shared/forms.ts +++ b/tests/e2e/shared/forms.ts @@ -10,6 +10,9 @@ export async function validate_form({page}: {page: Page}, scope: 'form' | 'field // legacy dropdowns don't use semantic HTML yet, // avoid using these where possible '.ui.dropdown', + // for some reason we use h1 to h5 for form sections, + // and it usually makes no sense semantically + '.ui.top.attached.header', ]; await accessibilityCheck({page}, [scope], excludedElements, []); diff --git a/tests/integration/api_user_search_test.go b/tests/integration/api_user_search_test.go index 6252c2ebde..0e8f7a123e 100644 --- a/tests/integration/api_user_search_test.go +++ b/tests/integration/api_user_search_test.go @@ -4,18 +4,25 @@ package integration import ( + "context" + "fmt" "net/http" + "strconv" "testing" + "time" auth_model "forgejo.org/models/auth" + "forgejo.org/models/db" "forgejo.org/models/unittest" user_model "forgejo.org/models/user" "forgejo.org/modules/setting" api "forgejo.org/modules/structs" "forgejo.org/modules/test" + "forgejo.org/modules/timeutil" "forgejo.org/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type SearchResults struct { @@ -179,3 +186,61 @@ func TestAPIUserSearchByEmail(t *testing.T) { assert.Len(t, results.Data, 1) assert.Equal(t, query, results.Data[0].Email) } + +func TestUsersSearchSorted(t *testing.T) { + defer tests.PrepareTestEnv(t)() + createTimestamp := time.Now().Unix() - 1000 + updateTimestamp := time.Now().Unix() - 500 + sess := db.GetEngine(context.Background()) + + for i := int64(1); i <= 10; i++ { + name := "sorttest" + strconv.Itoa(int(i)) + user := &user_model.User{ + Name: name, + LowerName: name, + LoginName: name, + Email: name + "@example.com", + Passwd: name + ".password", + Avatar: "xyz", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + CreatedUnix: timeutil.TimeStamp(createTimestamp - i), + UpdatedUnix: timeutil.TimeStamp(updateTimestamp - i), + } + _, err := sess.NoAutoTime().Insert(user) + require.NoError(t, err) + } + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + + testCases := []struct { + sortType string + expectedUsers []string + }{ + {"alphabetically", []string{"sorttest1", "sorttest10", "sorttest2", "sorttest3"}}, + {"reversealphabetically", []string{"sorttest9", "sorttest8", "sorttest7", "sorttest6"}}, + {"newest", []string{"sorttest1", "sorttest2", "sorttest3", "sorttest4"}}, + {"oldest", []string{"sorttest10", "sorttest9", "sorttest8", "sorttest7"}}, + {"recentupdate", []string{"sorttest1", "sorttest2", "sorttest3", "sorttest4"}}, + {"leastupdate", []string{"sorttest10", "sorttest9", "sorttest8", "sorttest7"}}, + } + + for _, testCase := range testCases { + req := NewRequest( + t, + "GET", + fmt.Sprintf("/api/v1/users/search?q=sorttest&sort=%s&limit=4", + testCase.sortType, + ), + ).AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + + var results SearchResults + DecodeJSON(t, resp, &results) + assert.Len(t, results.Data, 4) + for i, searchData := range results.Data { + assert.Equalf(t, testCase.expectedUsers[i], searchData.UserName, "Sort type: %s, index %d", testCase.sortType, i) + } + } +} diff --git a/tests/mysql.ini.tmpl b/tests/mysql.ini.tmpl index f44aff7594..3315d85a3f 100644 --- a/tests/mysql.ini.tmpl +++ b/tests/mysql.ini.tmpl @@ -92,6 +92,7 @@ DISABLE_GIT_HOOKS = false INSTALL_LOCK = true SECRET_KEY = 9pCviYTWSb INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTU1NTE2MTh9.hhSVGOANkaKk3vfCd2jDOIww4pUk0xtg9JRde5UogyQ +DISABLE_QUERY_AUTH_TOKEN = true [lfs] PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/data/lfs diff --git a/tests/pgsql.ini.tmpl b/tests/pgsql.ini.tmpl index 829fdc5b75..1e9b981800 100644 --- a/tests/pgsql.ini.tmpl +++ b/tests/pgsql.ini.tmpl @@ -97,6 +97,7 @@ DISABLE_GIT_HOOKS = false INSTALL_LOCK = true SECRET_KEY = 9pCviYTWSb INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTU1NTE2MTh9.hhSVGOANkaKk3vfCd2jDOIww4pUk0xtg9JRde5UogyQ +DISABLE_QUERY_AUTH_TOKEN = true [lfs] MINIO_BASE_PATH = lfs/ diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl index d36388405b..df6cea44ca 100644 --- a/tests/sqlite.ini.tmpl +++ b/tests/sqlite.ini.tmpl @@ -94,6 +94,7 @@ DISABLE_GIT_HOOKS = false INSTALL_LOCK = true SECRET_KEY = 9pCviYTWSb INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTI3OTU5ODN9.OQkH5UmzID2XBdwQ9TAI6Jj2t1X-wElVTjbE7aoN4I8 +DISABLE_QUERY_AUTH_TOKEN = true [oauth2] JWT_SECRET = KZb_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko