mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-19 22:48:32 +00:00
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:
+8
-40
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user