From 30982b9e7b625db984ced9aa7a65ca9cf1d17045 Mon Sep 17 00:00:00 2001 From: Dmitrii Sharshakov Date: Sat, 8 Mar 2025 10:42:36 +0000 Subject: [PATCH] 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 Co-authored-by: Dmitrii Sharshakov Co-committed-by: Dmitrii Sharshakov --- models/auth/access_token.go | 34 +++++++++++- models/auth/access_token_test.go | 25 +++++++++ options/locale/locale_en-US.ini | 4 ++ routers/web/user/setting/applications.go | 18 +++++++ routers/web/web.go | 1 + templates/user/settings/applications.tmpl | 15 ++++++ tests/integration/integration_test.go | 22 ++++++-- tests/integration/user_test.go | 64 +++++++++++++++++++++++ 8 files changed, 176 insertions(+), 7 deletions(-) diff --git a/models/auth/access_token.go b/models/auth/access_token.go index 63331b4841..3ac18940a8 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -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) +} diff --git a/models/auth/access_token_test.go b/models/auth/access_token_test.go index e6ea4876e5..976ff37493 100644 --- a/models/auth/access_token_test.go +++ b/models/auth/access_token_test.go @@ -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) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 8459d72f9e..0cf79445d3 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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) diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index 24ebf9b922..4dfd859a44 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -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}) diff --git a/routers/web/web.go b/routers/web/web.go index 128d072741..9706319b3b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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). diff --git a/templates/user/settings/applications.tmpl b/templates/user/settings/applications.tmpl index 2aeabc6903..38ede95a77 100644 --- a/templates/user/settings/applications.tmpl +++ b/templates/user/settings/applications.tmpl @@ -40,6 +40,10 @@
+
+ +