enhance(admin): allow regenerating access tokens for bot accounts (#40109)

Bot accounts cannot sign in, so admins manage their access tokens, but
the regenerate button was hidden for them. This adds an admin route to
regenerate a bot's token.

Token regeneration is now also recorded in the audit log.

Fixes: https://github.com/go-gitea/gitea/issues/40074
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Shivansh Gargandsilverwind authored and GitHub committed 2026-10-31 09:31:56 +00:00
1 parent 08c5fcafd3
commit 365cb2bd5e
6 files changed
+41 -26

No files matched your search

+1
View File
@@ -91,6 +91,7 @@ var (
UserOpenIDAdd = define("user:openid:add", "Associated OpenID {openid} to user {scope}.")
UserOpenIDRemove = define("user:openid:remove", "Removed OpenID {openid} from user {scope}.")
UserAccessTokenAdd = define("user:accesstoken:add", "Added access token {token} for user {scope} with scope {token_scope}.")
UserAccessTokenRegenerate = define("user:accesstoken:regenerate", "Regenerated access token {token} of user {scope}.")
UserAccessTokenRemove = define("user:accesstoken:remove", "Removed access token {token} from user {scope}.")
UserOAuth2ApplicationAdd = define("user:oauth2application:add", "Added OAuth2 application {oauth2_application} for user {scope}.")
UserOAuth2ApplicationUpdate = define("user:oauth2application:update", "Updated OAuth2 application {oauth2_application} of user {scope}.")
+6
View File
@@ -388,6 +388,12 @@ func DeleteBotToken(ctx *context.Context) {
}
}
func RegenerateBotToken(ctx *context.Context) {
if u := getTargetBot(ctx); u != nil {
user_setting.RegenerateAccessToken(ctx, u)
}
}
func editUserCommon(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users.edit_account")
ctx.Data["PageIsAdminUsers"] = true
+11 -4
View File
@@ -167,16 +167,23 @@ func DeleteAccessToken(ctx *context.Context, owner *user_model.User) {
ctx.JSONRedirect("")
}
// 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)
// ApplicationsRegenerateToken response for regenerating the current user's access token
func ApplicationsRegenerateToken(ctx *context.Context) {
RegenerateAccessToken(ctx, ctx.Doer)
}
// RegenerateAccessToken regenerates an access token belonging to owner
func RegenerateAccessToken(ctx *context.Context, owner *user_model.User) {
t, err := auth_model.RegenerateAccessToken(ctx, ctx.FormInt64("id"), owner.ID)
if err != nil {
ctx.ServerError("RegenerateAccessToken", err)
return
}
audit.Record(ctx, audit_model.UserAccessTokenRegenerate, owner, "token", t.Name)
ctx.Flash.Success(ctx.Tr("settings.generate_token_success"))
ctx.Flash.Info(t.Token)
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
ctx.JSONRedirect("")
}
func loadApplicationsData(ctx *context.Context) {
+2 -1
View File
@@ -695,7 +695,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Combo("").Get(user_setting.Applications).
Post(user_setting.ApplicationsPost)
m.Post("/delete", user_setting.DeleteApplication)
m.Post("/regenerate", user_setting.RegenerateAccessToken)
m.Post("/regenerate", user_setting.ApplicationsRegenerateToken)
})
m.Combo("/keys").Get(user_setting.Keys).
@@ -824,6 +824,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/{userid}/avatar/delete", admin.DeleteAvatar)
m.Post("/{userid}/access_tokens", admin.NewBotTokenPost)
m.Post("/{userid}/access_tokens/delete", admin.DeleteBotToken)
m.Post("/{userid}/access_tokens/regenerate", admin.RegenerateBotToken)
m.Post("/{userid}/orgs/{org_id}/remove", admin.RemoveUserFromOrg)
m.Post("/{userid}/orgs/remove-all", admin.RemoveUserFromAllOrgs)
})
+13 -17
View File
@@ -48,12 +48,10 @@
</div>
</div>
<div class="item-trailing">
{{if not $.IsBot}}
<button type="button" class="ui tiny button link-action" data-modal-confirm="#regenerate-token" data-url="{{$.Link}}/regenerate?id={{$token.ID}}">
{{svg "octicon-sync"}}
{{ctx.Locale.Tr "settings.regenerate_token"}}
</button>
{{end}}
<button type="button" class="ui tiny button link-action" data-modal-confirm="#regenerate-token" data-url="{{$.Link}}/regenerate?id={{$token.ID}}">
{{svg "octicon-sync"}}
{{ctx.Locale.Tr "settings.regenerate_token"}}
</button>
<button type="button" class="ui red tiny button link-action" data-modal-confirm="#delete-token" data-url="{{$.Link}}/delete?id={{$token.ID}}">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_token"}}
@@ -112,14 +110,12 @@
{{template "base/modal_actions_confirm"}}
</dialog>
{{if not .IsBot}}
<dialog class="modal-dialog" 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"}}
</dialog>
{{end}}
<dialog class="modal-dialog" 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"}}
</dialog>
+8 -4
View File
@@ -220,6 +220,7 @@ func TestAdminBotUser(t *testing.T) {
assert.Empty(t, doc.Find("#password").Nodes)
doc = NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/-/admin/users/%d", bot.ID)), http.StatusOK).Body)
assert.NotEmpty(t, doc.Find(`form[action$="/access_tokens"]`).Nodes)
assert.NotEmpty(t, doc.Find("dialog#regenerate-token").Nodes)
})
t.Run("EditWithoutAuthSource", func(t *testing.T) {
@@ -264,12 +265,15 @@ func TestAdminBotUser(t *testing.T) {
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{UID: 2, Name: "not-a-bot"})
token := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{UID: bot.ID, Name: "ci"})
session.MakeRequest(t, NewRequestWithValues(t, "POST", tokenURL+"/delete", map[string]string{
"id": strconv.FormatInt(token.ID, 10),
}), http.StatusOK)
tokenID := map[string]string{"id": strconv.FormatInt(token.ID, 10)}
session.MakeRequest(t, NewRequestWithValues(t, "POST", tokenURL+"/regenerate", tokenID), http.StatusOK)
assert.NotEqual(t, token.TokenHash, unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: token.ID}).TokenHash)
session.MakeRequest(t, NewRequestWithValues(t, "POST", "/-/admin/users/2/access_tokens/regenerate", map[string]string{"id": "3"}), http.StatusBadRequest)
session.MakeRequest(t, NewRequestWithValues(t, "POST", tokenURL+"/delete", tokenID), http.StatusOK)
assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{UID: bot.ID}))
for _, action := range []audit_model.Action{audit_model.UserAccessTokenAdd, audit_model.UserAccessTokenRemove} {
for _, action := range []audit_model.Action{audit_model.UserAccessTokenAdd, audit_model.UserAccessTokenRegenerate, audit_model.UserAccessTokenRemove} {
events, _, err := audit_model.FindEvents(t.Context(), &audit_model.EventSearchOptions{Action: action, ScopeType: audit_model.ScopeUser, ScopeID: bot.ID})
require.NoError(t, err)
require.Len(t, events, 1, "audit events for %s", action)