mirror of
1
Fork 0

feat(auth): add ability to regenerate access tokens (#6963)

- Add the ability to regenerate existing access tokens in the UI. This preserves the ID of the access token, but generates a new salt and token contents.
- Integration test added.
- Unit test added.
- Resolves #6880

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6963
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Dmitrii Sharshakov <d3dx12.xx@gmail.com>
Co-committed-by: Dmitrii Sharshakov <d3dx12.xx@gmail.com>
This commit is contained in:
Dmitrii Sharshakov 2025-03-08 10:42:36 +00:00 committed by Gusted
parent 9dea54a9d6
commit 30982b9e7b
8 changed files with 176 additions and 7 deletions

View File

@ -98,6 +98,15 @@ func init() {
// NewAccessToken creates new access token.
func NewAccessToken(ctx context.Context, t *AccessToken) error {
err := generateAccessToken(t)
if err != nil {
return err
}
_, err = db.GetEngine(ctx).Insert(t)
return err
}
func generateAccessToken(t *AccessToken) error {
salt, err := util.CryptoRandomString(10)
if err != nil {
return err
@ -110,8 +119,7 @@ func NewAccessToken(ctx context.Context, t *AccessToken) error {
t.Token = hex.EncodeToString(token)
t.TokenHash = HashToken(t.Token, t.TokenSalt)
t.TokenLastEight = t.Token[len(t.Token)-8:]
_, err = db.GetEngine(ctx).Insert(t)
return err
return nil
}
// DisplayPublicOnly whether to display this as a public-only token.
@ -234,3 +242,25 @@ func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error {
}
return nil
}
// RegenerateAccessTokenByID regenerates access token by given ID.
// It regenerates token and salt, as well as updates the creation time.
func RegenerateAccessTokenByID(ctx context.Context, id, userID int64) (*AccessToken, error) {
t := &AccessToken{}
found, err := db.GetEngine(ctx).Where("id = ? AND uid = ?", id, userID).Get(t)
if err != nil {
return nil, err
} else if !found {
return nil, ErrAccessTokenNotExist{}
}
err = generateAccessToken(t)
if err != nil {
return nil, err
}
// Reset the creation time, token is unused
t.UpdatedUnix = timeutil.TimeStampNow()
return t, UpdateAccessToken(ctx, t)
}

View File

@ -131,3 +131,28 @@ func TestDeleteAccessTokenByID(t *testing.T) {
require.Error(t, err)
assert.True(t, auth_model.IsErrAccessTokenNotExist(err))
}
func TestRegenerateAccessTokenByID(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
token, err := auth_model.GetAccessTokenBySHA(db.DefaultContext, "4c6f36e6cf498e2a448662f915d932c09c5a146c")
require.NoError(t, err)
newToken, err := auth_model.RegenerateAccessTokenByID(db.DefaultContext, token.ID, 1)
require.NoError(t, err)
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: token.ID, UID: token.UID, TokenHash: token.TokenHash})
newToken = &auth_model.AccessToken{
ID: newToken.ID,
UID: newToken.UID,
TokenHash: newToken.TokenHash,
}
unittest.AssertExistsAndLoadBean(t, newToken)
// Token has been recreated, new salt and hash, but should retain the same ID, UID, Name and Scope
assert.Equal(t, token.ID, newToken.ID)
assert.NotEqual(t, token.TokenHash, newToken.TokenHash)
assert.NotEqual(t, token.TokenSalt, newToken.TokenSalt)
assert.Equal(t, token.UID, newToken.UID)
assert.Equal(t, token.Name, newToken.Name)
assert.Equal(t, token.Scope, newToken.Scope)
}

View File

@ -943,6 +943,10 @@ delete_token = Delete
access_token_deletion = Delete access token
access_token_deletion_desc = Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue?
delete_token_success = The token has been deleted. Applications using it no longer have access to your account.
regenerate_token = Regenerate
access_token_regeneration = Regenerate access token
access_token_regeneration_desc = Regenerating a token will revoke access to your account for applications using it. This cannot be undone. Continue?
regenerate_token_success = The token has been regenerated. Applications that use it no longer have access to your account and must be updated with the new token.
repo_and_org_access = Repository and Organization Access
permissions_public_only = Public only
permissions_access_all = All (public, private, and limited)

View File

@ -10,6 +10,7 @@ import (
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
@ -87,6 +88,23 @@ func DeleteApplication(ctx *context.Context) {
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
}
// RegenerateApplication response for regenerating user access token
func RegenerateApplication(ctx *context.Context) {
if t, err := auth_model.RegenerateAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
if auth_model.IsErrAccessTokenNotExist(err) {
ctx.Flash.Error(ctx.Tr("error.not_found"))
} else {
ctx.Flash.Error(ctx.Tr("error.server_internal"))
log.Error("DeleteAccessTokenByID", err)
}
} else {
ctx.Flash.Success(ctx.Tr("settings.regenerate_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

@ -586,6 +586,7 @@ func registerRoutes(m *web.Route) {
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.RegenerateApplication)
})
m.Combo("/keys").Get(user_setting.Keys).

View File

@ -40,6 +40,10 @@
</div>
</div>
<div class="flex-item-trailing">
<button class="ui primary tiny button delete-button" data-modal-id="regenerate-token" data-url="{{$.Link}}/regenerate" data-id="{{.ID}}">
{{svg "octicon-issue-reopened" 16 "tw-mr-1"}}
{{ctx.Locale.Tr "settings.regenerate_token"}}
</button>
<button class="ui red tiny button delete-button" data-modal-id="delete-token" data-url="{{$.Link}}/delete" data-id="{{.ID}}">
{{svg "octicon-trash" 16 "tw-mr-1"}}
{{ctx.Locale.Tr "settings.delete_token"}}
@ -99,6 +103,17 @@
{{end}}
</div>
<div class="ui g-modal-confirm delete modal" id="regenerate-token">
<div class="header">
{{svg "octicon-issue-reopened"}}
{{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" (dict "ModalButtonColors" "primary")}}
</div>
<div class="ui g-modal-confirm delete modal" id="delete-token">
<div class="header">
{{svg "octicon-trash"}}

View File

@ -421,7 +421,15 @@ var tokenCounter int64
// but without the "scope_" prefix.
func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.AccessTokenScope) string {
t.Helper()
var token string
accessTokenName := fmt.Sprintf("api-testing-token-%d", atomic.AddInt64(&tokenCounter, 1))
createApplicationSettingsToken(t, session, accessTokenName, scopes...)
token := assertAccessToken(t, session)
return token
}
// createApplicationSettingsToken creates a token with given name and scopes for the currently logged in user.
// It will assert CSRF token and redirect to the application settings page.
func createApplicationSettingsToken(t testing.TB, session *TestSession, name string, scopes ...auth.AccessTokenScope) {
req := NewRequest(t, "GET", "/user/settings/applications")
resp := session.MakeRequest(t, req, http.StatusOK)
var csrf string
@ -439,7 +447,7 @@ func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.
assert.NotEmpty(t, csrf)
urlValues := url.Values{}
urlValues.Add("_csrf", csrf)
urlValues.Add("name", fmt.Sprintf("api-testing-token-%d", atomic.AddInt64(&tokenCounter, 1)))
urlValues.Add("name", name)
for _, scope := range scopes {
urlValues.Add("scope", string(scope))
}
@ -458,11 +466,15 @@ func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.
}
}
}
}
req = NewRequest(t, "GET", "/user/settings/applications")
resp = session.MakeRequest(t, req, http.StatusOK)
// assertAccessToken retrieves a token from "/user/settings/applications" and returns it.
// It will also assert that the page contains a token.
func assertAccessToken(t testing.TB, session *TestSession) string {
req := NewRequest(t, "GET", "/user/settings/applications")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
token = htmlDoc.doc.Find(".ui.info p").Text()
token := htmlDoc.doc.Find(".ui.info p").Text()
assert.NotEmpty(t, token)
return token
}

View File

@ -30,6 +30,7 @@ import (
"code.gitea.io/gitea/services/mailer"
"code.gitea.io/gitea/tests"
"github.com/PuerkitoBio/goquery"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -247,6 +248,69 @@ func testExportUserGPGKeys(t *testing.T, user, expected string) {
assert.Equal(t, expected, resp.Body.String())
}
func TestAccessTokenRegenerate(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1")
prevLatestTokenName, prevLatestTokenID := findLatestTokenID(t, session)
createApplicationSettingsToken(t, session, "TestAccessToken", auth_model.AccessTokenScopeWriteUser)
oldToken := assertAccessToken(t, session)
oldTokenName, oldTokenID := findLatestTokenID(t, session)
assert.Equal(t, "TestAccessToken", oldTokenName)
req := NewRequestWithValues(t, "POST", "/user/settings/applications/regenerate", map[string]string{
"_csrf": GetCSRF(t, session, "/user/settings/applications"),
"id": strconv.Itoa(oldTokenID),
})
session.MakeRequest(t, req, http.StatusOK)
newToken := assertAccessToken(t, session)
newTokenName, newTokenID := findLatestTokenID(t, session)
assert.NotEqual(t, oldToken, newToken)
assert.Equal(t, oldTokenID, newTokenID)
assert.Equal(t, "TestAccessToken", newTokenName)
req = NewRequestWithValues(t, "POST", "/user/settings/applications/delete", map[string]string{
"_csrf": GetCSRF(t, session, "/user/settings/applications"),
"id": strconv.Itoa(newTokenID),
})
session.MakeRequest(t, req, http.StatusOK)
latestTokenName, latestTokenID := findLatestTokenID(t, session)
assert.Less(t, latestTokenID, oldTokenID)
assert.Equal(t, latestTokenID, prevLatestTokenID)
assert.Equal(t, latestTokenName, prevLatestTokenName)
assert.NotEqual(t, "TestAccessToken", latestTokenName)
}
func findLatestTokenID(t *testing.T, session *TestSession) (string, int) {
req := NewRequest(t, "GET", "/user/settings/applications")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
latestTokenName := ""
latestTokenID := 0
htmlDoc.Find(".delete-button").Each(func(i int, s *goquery.Selection) {
tokenID, exists := s.Attr("data-id")
if !exists || tokenID == "" {
return
}
id, err := strconv.Atoi(tokenID)
require.NoError(t, err)
if id > latestTokenID {
latestTokenName = s.Parent().Parent().Find(".flex-item-title").Text()
latestTokenID = id
}
})
return latestTokenName, latestTokenID
}
func TestGetUserRss(t *testing.T) {
defer tests.PrepareTestEnv(t)()