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

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
}

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
}
}

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())

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
})

View File

@@ -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 {

View File

@@ -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)",

View File

@@ -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")
}

View File

@@ -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})

View File

@@ -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).

View File

@@ -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.

View File

@@ -40,6 +40,10 @@
</div>
</div>
<div class="item-trailing">
<button class="ui tiny button link-action" data-modal-confirm="#regenerate-token" data-url="{{$.Link}}/regenerate?id={{.ID}}">
{{svg "octicon-sync"}}
{{ctx.Locale.Tr "settings.regenerate_token"}}
</button>
<button class="ui red tiny button link-action" data-modal-confirm="#delete-token" data-url="{{$.Link}}/delete?id={{.ID}}">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_token"}}
@@ -103,4 +107,14 @@
{{template "base/modal_actions_confirm"}}
</div>
<div class="ui small modal" id="regenerate-token">
<div class="header">
{{ctx.Locale.Tr "settings.access_token_regeneration"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "settings.access_token_regeneration_desc"}}</p>
</div>
{{template "base/modal_actions_confirm"}}
</div>
{{template "user/settings/layout_footer" .}}

View File

@@ -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)()