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
}