diff --git a/models/actions/task.go b/models/actions/task.go index 2eba3cdb178..15dec10be29 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -5,7 +5,6 @@ package actions import ( "context" - "crypto/subtle" "errors" "fmt" "strings" @@ -22,7 +21,6 @@ import ( "gitea.dev/modules/timeutil" "gitea.dev/modules/util" - lru "github.com/hashicorp/golang-lru/v2" "google.golang.org/protobuf/types/known/timestamppb" "xorm.io/builder" ) @@ -66,21 +64,8 @@ type ActionTask struct { // it only decides whether the runner is reachable, not whether the task should be killed. const taskReportTimeout = time.Minute -var successfulTokenTaskCache *lru.Cache[string, any] - func init() { - db.RegisterModel(new(ActionTask), func() error { - if setting.SuccessfulTokensCacheSize > 0 { - var err error - successfulTokenTaskCache, err = lru.New[string, any](setting.SuccessfulTokensCacheSize) - if err != nil { - return fmt.Errorf("unable to allocate Task cache: %v", err) - } - } else { - successfulTokenTaskCache = nil - } - return nil - }) + db.RegisterModel(new(ActionTask)) } func (task *ActionTask) Duration() time.Duration { @@ -195,21 +180,21 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro } } + cacheKey := "actions:" + token lastEight := token[len(token)-8:] - - if id := getTaskIDFromCache(token); id > 0 { + if cached, _ := auth_model.TokenCache().Get(cacheKey); cached != nil { task := &ActionTask{ TokenLastEight: lastEight, } // Re-get the task from the db in case it has been deleted in the intervening period - has, err := db.GetEngine(ctx).ID(id).Get(task) + has, err := db.GetEngine(ctx).ID(cached.TokenID).Get(task) if err != nil { return nil, err } - if has { + if has && util.CryptoConstTimeEqual(task.TokenHash, cached.TokenHash) { return task, nil } - successfulTokenTaskCache.Remove(token) + auth_model.TokenCache().Remove(cacheKey) } var tasks []*ActionTask @@ -223,10 +208,8 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro for _, t := range tasks { tempHash := auth_model.HashToken(token, t.TokenSalt) - if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(tempHash)) == 1 { - if successfulTokenTaskCache != nil { - successfulTokenTaskCache.Add(token, t.ID) - } + if util.CryptoConstTimeEqual(t.TokenHash, tempHash) { + auth_model.TokenCache().Add(cacheKey, &auth_model.TokenCacheItem{TokenID: t.ID, TokenHash: t.TokenHash}) return t, nil } } @@ -671,18 +654,3 @@ func logFileName(repoFullName string, taskID int64) string { return ret } - -func getTaskIDFromCache(token string) int64 { - if successfulTokenTaskCache == nil { - return 0 - } - tInterface, ok := successfulTokenTaskCache.Get(token) - if !ok { - return 0 - } - t, ok := tInterface.(int64) - if !ok { - return 0 - } - return t -} diff --git a/models/auth/access_token.go b/models/auth/access_token.go index 63a345dfcdc..5d324174a0d 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -6,22 +6,16 @@ package auth import ( "context" - "crypto/subtle" "encoding/hex" - "fmt" "time" "gitea.dev/models/db" - "gitea.dev/modules/setting" "gitea.dev/modules/timeutil" "gitea.dev/modules/util" - lru "github.com/hashicorp/golang-lru/v2" "xorm.io/builder" ) -var successfulAccessTokenCache *lru.Cache[string, any] - // AccessToken represents a personal access token. type AccessToken struct { ID int64 `xorm:"pk autoincr"` @@ -46,32 +40,43 @@ func (t *AccessToken) AfterLoad() { } func init() { - db.RegisterModel(new(AccessToken), func() error { - if setting.SuccessfulTokensCacheSize > 0 { - var err error - successfulAccessTokenCache, err = lru.New[string, any](setting.SuccessfulTokensCacheSize) - if err != nil { - return fmt.Errorf("unable to allocate AccessToken cache: %w", err) - } - } else { - successfulAccessTokenCache = nil - } - return nil - }) + db.RegisterModel(new(AccessToken)) } -// NewAccessToken creates new access token. -func NewAccessToken(ctx context.Context, t *AccessToken) error { +// setNewTokenValue generates a fresh random token value and fills in its salt, hash, and last-eight. +func (t *AccessToken) setNewTokenValue() { salt := util.CryptoRandomString(10) token := util.CryptoRandomBytes(20) t.TokenSalt = salt t.Token = hex.EncodeToString(token) t.TokenHash = HashToken(t.Token, t.TokenSalt) t.TokenLastEight = t.Token[len(t.Token)-8:] +} + +// NewAccessToken creates new access token. +func NewAccessToken(ctx context.Context, t *AccessToken) error { + t.setNewTokenValue() _, err := db.GetEngine(ctx).Insert(t) return err } +// RegenerateAccessToken regenerates the token value of an existing access token owned by userID, keeping its name and scope. +func RegenerateAccessToken(ctx context.Context, id, userID int64) (*AccessToken, error) { + t := &AccessToken{} + has, err := db.GetEngine(ctx).Where("id=? AND uid=?", id, userID).Get(t) + if err != nil { + return nil, err + } else if !has { + return nil, util.NewNotExistErrorf("access token not found") + } + + t.setNewTokenValue() + if _, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "token_salt", "token_last_eight").NoAutoTime().Update(t); err != nil { + return nil, err + } + return t, nil +} + // DisplayPublicOnly whether to display this as a public-only token. func (t *AccessToken) DisplayPublicOnly() bool { publicOnly, err := t.Scope.PublicOnly() @@ -81,41 +86,26 @@ func (t *AccessToken) DisplayPublicOnly() bool { return publicOnly } -func getAccessTokenIDFromCache(token string) int64 { - if successfulAccessTokenCache == nil { - return 0 - } - tInterface, ok := successfulAccessTokenCache.Get(token) - if !ok { - return 0 - } - t, ok := tInterface.(int64) - if !ok { - return 0 - } - return t -} - // GetAccessTokenBySHA returns access token by given token value func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error) { if len(token) < 8 { return nil, util.NewNotExistErrorf("access token not found") } + cacheKey := "access:" + token lastEight := token[len(token)-8:] - if id := getAccessTokenIDFromCache(token); id > 0 { - accessToken := &AccessToken{ - TokenLastEight: lastEight, - } - // Re-get the token from the db in case it has been deleted in the intervening period - has, err := db.GetEngine(ctx).ID(id).Get(accessToken) + if cached, _ := TokenCache().Get(cacheKey); cached != nil { + // Re-get the token from the db in case it has been deleted or regenerated in the intervening period + accessToken := &AccessToken{} + has, err := db.GetEngine(ctx).ID(cached.TokenID).Get(accessToken) if err != nil { return nil, err } - if has { + if has && util.CryptoConstTimeEqual(accessToken.TokenHash, cached.TokenHash) { return accessToken, nil } - successfulAccessTokenCache.Remove(token) + // either the token has been deleted or changed, invalidate the cache + TokenCache().Remove(cacheKey) } var tokens []AccessToken @@ -128,10 +118,8 @@ func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error for _, t := range tokens { tempHash := HashToken(token, t.TokenSalt) - if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(tempHash)) == 1 { - if successfulAccessTokenCache != nil { - successfulAccessTokenCache.Add(token, t.ID) - } + if util.CryptoConstTimeEqual(t.TokenHash, tempHash) { + TokenCache().Add(cacheKey, &TokenCacheItem{TokenID: t.ID, TokenHash: t.TokenHash}) return &t, nil } } diff --git a/models/auth/access_token_test.go b/models/auth/access_token_test.go index acab8b3ab50..f2645aab118 100644 --- a/models/auth/access_token_test.go +++ b/models/auth/access_token_test.go @@ -117,6 +117,46 @@ func TestUpdateAccessToken(t *testing.T) { unittest.AssertExistsAndLoadBean(t, token) } +func TestRegenerateAccessToken(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const oldToken = "d2c6c1ba3890b309189a8e618c72a162e4efbf36" + + // prime the successful-lookup cache with the old token value, as a real request would + before, err := auth_model.GetAccessTokenBySHA(t.Context(), oldToken) + assert.NoError(t, err) + assert.Equal(t, "Token A", before.Name) + + regenerated, err := auth_model.RegenerateAccessToken(t.Context(), before.ID, before.UID) + assert.NoError(t, err) + assert.Equal(t, before.ID, regenerated.ID) + assert.Equal(t, before.Name, regenerated.Name) + assert.Equal(t, before.Scope, regenerated.Scope) + assert.NotEqual(t, before.TokenHash, regenerated.TokenHash) + assert.NotEmpty(t, regenerated.Token) + + // the old token value must stop authenticating, even though it was cached as successful above + _, err = auth_model.GetAccessTokenBySHA(t.Context(), oldToken) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) + + // the new token value must authenticate + found, err := auth_model.GetAccessTokenBySHA(t.Context(), regenerated.Token) + assert.NoError(t, err) + assert.Equal(t, before.ID, found.ID) + assert.Equal(t, before.UpdatedUnix, found.UpdatedUnix) + + // wrong owner + _, err = auth_model.RegenerateAccessToken(t.Context(), before.ID, before.UID+1) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) + + // nonexistent token + _, err = auth_model.RegenerateAccessToken(t.Context(), 100, 100) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) +} + func TestDeleteAccessTokenByID(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/models/auth/token_cache.go b/models/auth/token_cache.go new file mode 100644 index 00000000000..a2719bbf776 --- /dev/null +++ b/models/auth/token_cache.go @@ -0,0 +1,23 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "sync" + + "gitea.dev/modules/setting" + + lru "github.com/hashicorp/golang-lru/v2" +) + +type TokenCacheItem struct { + TokenID int64 + TokenHash string +} + +var TokenCache = sync.OnceValue(func() *lru.Cache[string, *TokenCacheItem] { + cacheSize := max(setting.SuccessfulTokensCacheSize, 20) + c, _ := lru.New[string, *TokenCacheItem](cacheSize) // it only fails when size <= 0 + return c +}) diff --git a/modules/util/util.go b/modules/util/util.go index 13816182a3a..19f458fb35f 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -6,6 +6,7 @@ package util import ( "bytes" "crypto/rand" + "crypto/subtle" "encoding/hex" "fmt" "math/big" @@ -99,6 +100,10 @@ func CryptoRandomBytes(length int64) []byte { return buf } +func CryptoConstTimeEqual[T string | []byte](a, b T) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + var chaCha8RandPool = sync.OnceValue(func() *sync.Pool { return &sync.Pool{ New: func() any { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index fb76a2dacde..aca025044eb 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -854,6 +854,9 @@ "settings.access_token_deletion_confirm_action": "Delete", "settings.access_token_deletion_desc": "Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue?", "settings.delete_token_success": "The token has been deleted. Applications using it no longer have access to your account.", + "settings.regenerate_token": "Regenerate", + "settings.access_token_regeneration": "Regenerate Access Token", + "settings.access_token_regeneration_desc": "Regenerating a token immediately invalidates its old value; applications still using the old value will lose access. Its name and permissions are kept. This cannot be undone. Continue?", "settings.repo_and_org_access": "Repository and Organization Access", "settings.permissions_public_only": "Public only", "settings.permissions_access_all": "All (public, private, and limited)", diff --git a/routers/api/actions/runner/interceptor.go b/routers/api/actions/runner/interceptor.go index 598a940baff..8b79962c11d 100644 --- a/routers/api/actions/runner/interceptor.go +++ b/routers/api/actions/runner/interceptor.go @@ -5,7 +5,6 @@ package runner import ( "context" - "crypto/subtle" "errors" "strings" "time" @@ -43,7 +42,7 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar } return nil, status.Error(codes.Internal, err.Error()) } - if subtle.ConstantTimeCompare([]byte(runner.TokenHash), []byte(auth_model.HashToken(token, runner.TokenSalt))) != 1 { + if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) { return nil, status.Error(codes.Unauthenticated, "unregistered runner") } diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index 6347d8ebdfb..b13b1718ce0 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -121,6 +121,18 @@ func DeleteApplication(ctx *context.Context) { ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") } +// RegenerateAccessToken response for regenerating a user's access token +func RegenerateAccessToken(ctx *context.Context) { + t, err := auth_model.RegenerateAccessToken(ctx, ctx.FormInt64("id"), ctx.Doer.ID) + if err != nil { + ctx.ServerError("RegenerateAccessToken", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) + ctx.Flash.Info(t.Token) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") +} + func loadApplicationsData(ctx *context.Context) { ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) diff --git a/routers/web/web.go b/routers/web/web.go index 211c03b2c1f..9dff831baaf 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -695,6 +695,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Combo("").Get(user_setting.Applications). Post(web.Bind[*forms.NewAccessTokenForm](), user_setting.ApplicationsPost) m.Post("/delete", user_setting.DeleteApplication) + m.Post("/regenerate", user_setting.RegenerateAccessToken) }) m.Combo("/keys").Get(user_setting.Keys). diff --git a/services/auth/auth_token.go b/services/auth/auth_token.go index f3bf723f226..809dff5b261 100644 --- a/services/auth/auth_token.go +++ b/services/auth/auth_token.go @@ -6,7 +6,6 @@ package auth import ( "context" "crypto/sha256" - "crypto/subtle" "encoding/hex" "errors" "strings" @@ -54,7 +53,7 @@ func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, e hashedToken := sha256.Sum256([]byte(parts[1])) - if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(hex.EncodeToString(hashedToken[:]))) == 0 { + if !util.CryptoConstTimeEqual(t.TokenHash, hex.EncodeToString(hashedToken[:])) { // If an attacker steals a token and uses the token to create a new session the hash gets updated. // When the victim uses the old token the hashes don't match anymore and the victim should be notified about the compromised token. // Revoke the token so the attacker's rotated token (which shares this ID) can no longer be used. diff --git a/templates/user/settings/applications.tmpl b/templates/user/settings/applications.tmpl index 7c3da41e33a..e0642c2c2f9 100644 --- a/templates/user/settings/applications.tmpl +++ b/templates/user/settings/applications.tmpl @@ -40,6 +40,10 @@
+
+ + {{template "user/settings/layout_footer" .}} diff --git a/tests/integration/user_settings_test.go b/tests/integration/user_settings_test.go index d837da71cd2..e30904cc8a5 100644 --- a/tests/integration/user_settings_test.go +++ b/tests/integration/user_settings_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" + auth_model "gitea.dev/models/auth" + "gitea.dev/models/unittest" "gitea.dev/modules/container" "gitea.dev/modules/setting" "gitea.dev/modules/test" @@ -283,6 +285,25 @@ func TestUserSettingsApplications(t *testing.T) { assertNavbar(t, doc) }) + t.Run("RegenerateAccessToken", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user2") + + before := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 3, UID: 2}) + + req := NewRequestWithValues(t, "POST", "/user/settings/applications/regenerate", map[string]string{ + "id": "3", + }) + session.MakeRequest(t, req, http.StatusOK) + + after := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 3, UID: 2}) + assert.Equal(t, before.Name, after.Name) + assert.Equal(t, before.Scope, after.Scope) + assert.NotEqual(t, before.TokenHash, after.TokenHash) + assert.NotEqual(t, before.TokenSalt, after.TokenSalt) + }) + t.Run("OAuth2", func(t *testing.T) { defer tests.PrintCurrentTest(t)()