feat(user): Personal access tokens can be regenerated (#38907)

Lets users regenerate a personal access token's value in place, keeping
its name and scopes, instead of deleting and recreating it. Useful when
a token was shared with a third party (e.g. an AI agent) and needs to
be invalidated immediately without redoing scope selection.

Follows the same pattern already used for OAuth2 application client
secrets (`GenerateClientSecret`/`RegenerateSecret`).

**Testing**: added a model unit test and a web integration test;
manually
verified in the running dev server that the old token stops
authenticating
and the new one works immediately after regenerating.

<img width="1040" height="245" alt="image"
src="https://github.com/user-attachments/assets/4de0d8b4-1fc4-49cf-a859-95e24d0b2c0a"
/>

Fixes #38683.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Mitrahsoft
2026-08-17 23:47:16 +05:30
committed by GitHub
parent 346e6bab67
commit 7857c5f843
12 changed files with 164 additions and 91 deletions
+8 -40
View File
@@ -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
}
+35 -47
View File
@@ -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
}
}
+40
View File
@@ -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())
+23
View File
@@ -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
})