Skip to content

Commit 30982b9

Browse files
dssengGusted
authored andcommitted
feat(auth): add ability to regenerate access tokens (go-gitea#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 go-gitea#6880 Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6963 Reviewed-by: 0ko <[email protected]> Reviewed-by: Gusted <[email protected]> Co-authored-by: Dmitrii Sharshakov <[email protected]> Co-committed-by: Dmitrii Sharshakov <[email protected]>
1 parent 9dea54a commit 30982b9

File tree

8 files changed

+176
-7
lines changed

8 files changed

+176
-7
lines changed

models/auth/access_token.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ func init() {
9898

9999
// NewAccessToken creates new access token.
100100
func NewAccessToken(ctx context.Context, t *AccessToken) error {
101+
err := generateAccessToken(t)
102+
if err != nil {
103+
return err
104+
}
105+
_, err = db.GetEngine(ctx).Insert(t)
106+
return err
107+
}
108+
109+
func generateAccessToken(t *AccessToken) error {
101110
salt, err := util.CryptoRandomString(10)
102111
if err != nil {
103112
return err
@@ -110,8 +119,7 @@ func NewAccessToken(ctx context.Context, t *AccessToken) error {
110119
t.Token = hex.EncodeToString(token)
111120
t.TokenHash = HashToken(t.Token, t.TokenSalt)
112121
t.TokenLastEight = t.Token[len(t.Token)-8:]
113-
_, err = db.GetEngine(ctx).Insert(t)
114-
return err
122+
return nil
115123
}
116124

117125
// DisplayPublicOnly whether to display this as a public-only token.
@@ -234,3 +242,25 @@ func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error {
234242
}
235243
return nil
236244
}
245+
246+
// RegenerateAccessTokenByID regenerates access token by given ID.
247+
// It regenerates token and salt, as well as updates the creation time.
248+
func RegenerateAccessTokenByID(ctx context.Context, id, userID int64) (*AccessToken, error) {
249+
t := &AccessToken{}
250+
found, err := db.GetEngine(ctx).Where("id = ? AND uid = ?", id, userID).Get(t)
251+
if err != nil {
252+
return nil, err
253+
} else if !found {
254+
return nil, ErrAccessTokenNotExist{}
255+
}
256+
257+
err = generateAccessToken(t)
258+
if err != nil {
259+
return nil, err
260+
}
261+
262+
// Reset the creation time, token is unused
263+
t.UpdatedUnix = timeutil.TimeStampNow()
264+
265+
return t, UpdateAccessToken(ctx, t)
266+
}

models/auth/access_token_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,28 @@ func TestDeleteAccessTokenByID(t *testing.T) {
131131
require.Error(t, err)
132132
assert.True(t, auth_model.IsErrAccessTokenNotExist(err))
133133
}
134+
135+
func TestRegenerateAccessTokenByID(t *testing.T) {
136+
require.NoError(t, unittest.PrepareTestDatabase())
137+
138+
token, err := auth_model.GetAccessTokenBySHA(db.DefaultContext, "4c6f36e6cf498e2a448662f915d932c09c5a146c")
139+
require.NoError(t, err)
140+
141+
newToken, err := auth_model.RegenerateAccessTokenByID(db.DefaultContext, token.ID, 1)
142+
require.NoError(t, err)
143+
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: token.ID, UID: token.UID, TokenHash: token.TokenHash})
144+
newToken = &auth_model.AccessToken{
145+
ID: newToken.ID,
146+
UID: newToken.UID,
147+
TokenHash: newToken.TokenHash,
148+
}
149+
unittest.AssertExistsAndLoadBean(t, newToken)
150+
151+
// Token has been recreated, new salt and hash, but should retain the same ID, UID, Name and Scope
152+
assert.Equal(t, token.ID, newToken.ID)
153+
assert.NotEqual(t, token.TokenHash, newToken.TokenHash)
154+
assert.NotEqual(t, token.TokenSalt, newToken.TokenSalt)
155+
assert.Equal(t, token.UID, newToken.UID)
156+
assert.Equal(t, token.Name, newToken.Name)
157+
assert.Equal(t, token.Scope, newToken.Scope)
158+
}

options/locale/locale_en-US.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,10 @@ delete_token = Delete
943943
access_token_deletion = Delete access token
944944
access_token_deletion_desc = Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue?
945945
delete_token_success = The token has been deleted. Applications using it no longer have access to your account.
946+
regenerate_token = Regenerate
947+
access_token_regeneration = Regenerate access token
948+
access_token_regeneration_desc = Regenerating a token will revoke access to your account for applications using it. This cannot be undone. Continue?
949+
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.
946950
repo_and_org_access = Repository and Organization Access
947951
permissions_public_only = Public only
948952
permissions_access_all = All (public, private, and limited)

routers/web/user/setting/applications.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
auth_model "code.gitea.io/gitea/models/auth"
1111
"code.gitea.io/gitea/models/db"
1212
"code.gitea.io/gitea/modules/base"
13+
"code.gitea.io/gitea/modules/log"
1314
"code.gitea.io/gitea/modules/setting"
1415
"code.gitea.io/gitea/modules/web"
1516
"code.gitea.io/gitea/services/context"
@@ -87,6 +88,23 @@ func DeleteApplication(ctx *context.Context) {
8788
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
8889
}
8990

91+
// RegenerateApplication response for regenerating user access token
92+
func RegenerateApplication(ctx *context.Context) {
93+
if t, err := auth_model.RegenerateAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
94+
if auth_model.IsErrAccessTokenNotExist(err) {
95+
ctx.Flash.Error(ctx.Tr("error.not_found"))
96+
} else {
97+
ctx.Flash.Error(ctx.Tr("error.server_internal"))
98+
log.Error("DeleteAccessTokenByID", err)
99+
}
100+
} else {
101+
ctx.Flash.Success(ctx.Tr("settings.regenerate_token_success"))
102+
ctx.Flash.Info(t.Token)
103+
}
104+
105+
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
106+
}
107+
90108
func loadApplicationsData(ctx *context.Context) {
91109
ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly
92110
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID})

routers/web/web.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,7 @@ func registerRoutes(m *web.Route) {
586586
m.Combo("").Get(user_setting.Applications).
587587
Post(web.Bind(forms.NewAccessTokenForm{}), user_setting.ApplicationsPost)
588588
m.Post("/delete", user_setting.DeleteApplication)
589+
m.Post("/regenerate", user_setting.RegenerateApplication)
589590
})
590591

591592
m.Combo("/keys").Get(user_setting.Keys).

templates/user/settings/applications.tmpl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
</div>
4141
</div>
4242
<div class="flex-item-trailing">
43+
<button class="ui primary tiny button delete-button" data-modal-id="regenerate-token" data-url="{{$.Link}}/regenerate" data-id="{{.ID}}">
44+
{{svg "octicon-issue-reopened" 16 "tw-mr-1"}}
45+
{{ctx.Locale.Tr "settings.regenerate_token"}}
46+
</button>
4347
<button class="ui red tiny button delete-button" data-modal-id="delete-token" data-url="{{$.Link}}/delete" data-id="{{.ID}}">
4448
{{svg "octicon-trash" 16 "tw-mr-1"}}
4549
{{ctx.Locale.Tr "settings.delete_token"}}
@@ -99,6 +103,17 @@
99103
{{end}}
100104
</div>
101105

106+
<div class="ui g-modal-confirm delete modal" id="regenerate-token">
107+
<div class="header">
108+
{{svg "octicon-issue-reopened"}}
109+
{{ctx.Locale.Tr "settings.access_token_regeneration"}}
110+
</div>
111+
<div class="content">
112+
<p>{{ctx.Locale.Tr "settings.access_token_regeneration_desc"}}</p>
113+
</div>
114+
{{template "base/modal_actions_confirm" (dict "ModalButtonColors" "primary")}}
115+
</div>
116+
102117
<div class="ui g-modal-confirm delete modal" id="delete-token">
103118
<div class="header">
104119
{{svg "octicon-trash"}}

tests/integration/integration_test.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,15 @@ var tokenCounter int64
421421
// but without the "scope_" prefix.
422422
func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.AccessTokenScope) string {
423423
t.Helper()
424-
var token string
424+
accessTokenName := fmt.Sprintf("api-testing-token-%d", atomic.AddInt64(&tokenCounter, 1))
425+
createApplicationSettingsToken(t, session, accessTokenName, scopes...)
426+
token := assertAccessToken(t, session)
427+
return token
428+
}
429+
430+
// createApplicationSettingsToken creates a token with given name and scopes for the currently logged in user.
431+
// It will assert CSRF token and redirect to the application settings page.
432+
func createApplicationSettingsToken(t testing.TB, session *TestSession, name string, scopes ...auth.AccessTokenScope) {
425433
req := NewRequest(t, "GET", "/user/settings/applications")
426434
resp := session.MakeRequest(t, req, http.StatusOK)
427435
var csrf string
@@ -439,7 +447,7 @@ func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.
439447
assert.NotEmpty(t, csrf)
440448
urlValues := url.Values{}
441449
urlValues.Add("_csrf", csrf)
442-
urlValues.Add("name", fmt.Sprintf("api-testing-token-%d", atomic.AddInt64(&tokenCounter, 1)))
450+
urlValues.Add("name", name)
443451
for _, scope := range scopes {
444452
urlValues.Add("scope", string(scope))
445453
}
@@ -458,11 +466,15 @@ func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.
458466
}
459467
}
460468
}
469+
}
461470

462-
req = NewRequest(t, "GET", "/user/settings/applications")
463-
resp = session.MakeRequest(t, req, http.StatusOK)
471+
// assertAccessToken retrieves a token from "/user/settings/applications" and returns it.
472+
// It will also assert that the page contains a token.
473+
func assertAccessToken(t testing.TB, session *TestSession) string {
474+
req := NewRequest(t, "GET", "/user/settings/applications")
475+
resp := session.MakeRequest(t, req, http.StatusOK)
464476
htmlDoc := NewHTMLParser(t, resp.Body)
465-
token = htmlDoc.doc.Find(".ui.info p").Text()
477+
token := htmlDoc.doc.Find(".ui.info p").Text()
466478
assert.NotEmpty(t, token)
467479
return token
468480
}

tests/integration/user_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"code.gitea.io/gitea/services/mailer"
3131
"code.gitea.io/gitea/tests"
3232

33+
"github.com/PuerkitoBio/goquery"
3334
"github.com/pquerna/otp/totp"
3435
"github.com/stretchr/testify/assert"
3536
"github.com/stretchr/testify/require"
@@ -247,6 +248,69 @@ func testExportUserGPGKeys(t *testing.T, user, expected string) {
247248
assert.Equal(t, expected, resp.Body.String())
248249
}
249250

251+
func TestAccessTokenRegenerate(t *testing.T) {
252+
defer tests.PrepareTestEnv(t)()
253+
254+
session := loginUser(t, "user1")
255+
prevLatestTokenName, prevLatestTokenID := findLatestTokenID(t, session)
256+
257+
createApplicationSettingsToken(t, session, "TestAccessToken", auth_model.AccessTokenScopeWriteUser)
258+
oldToken := assertAccessToken(t, session)
259+
oldTokenName, oldTokenID := findLatestTokenID(t, session)
260+
261+
assert.Equal(t, "TestAccessToken", oldTokenName)
262+
263+
req := NewRequestWithValues(t, "POST", "/user/settings/applications/regenerate", map[string]string{
264+
"_csrf": GetCSRF(t, session, "/user/settings/applications"),
265+
"id": strconv.Itoa(oldTokenID),
266+
})
267+
session.MakeRequest(t, req, http.StatusOK)
268+
269+
newToken := assertAccessToken(t, session)
270+
newTokenName, newTokenID := findLatestTokenID(t, session)
271+
272+
assert.NotEqual(t, oldToken, newToken)
273+
assert.Equal(t, oldTokenID, newTokenID)
274+
assert.Equal(t, "TestAccessToken", newTokenName)
275+
276+
req = NewRequestWithValues(t, "POST", "/user/settings/applications/delete", map[string]string{
277+
"_csrf": GetCSRF(t, session, "/user/settings/applications"),
278+
"id": strconv.Itoa(newTokenID),
279+
})
280+
session.MakeRequest(t, req, http.StatusOK)
281+
282+
latestTokenName, latestTokenID := findLatestTokenID(t, session)
283+
284+
assert.Less(t, latestTokenID, oldTokenID)
285+
assert.Equal(t, latestTokenID, prevLatestTokenID)
286+
assert.Equal(t, latestTokenName, prevLatestTokenName)
287+
assert.NotEqual(t, "TestAccessToken", latestTokenName)
288+
}
289+
290+
func findLatestTokenID(t *testing.T, session *TestSession) (string, int) {
291+
req := NewRequest(t, "GET", "/user/settings/applications")
292+
resp := session.MakeRequest(t, req, http.StatusOK)
293+
htmlDoc := NewHTMLParser(t, resp.Body)
294+
latestTokenName := ""
295+
latestTokenID := 0
296+
htmlDoc.Find(".delete-button").Each(func(i int, s *goquery.Selection) {
297+
tokenID, exists := s.Attr("data-id")
298+
299+
if !exists || tokenID == "" {
300+
return
301+
}
302+
303+
id, err := strconv.Atoi(tokenID)
304+
require.NoError(t, err)
305+
if id > latestTokenID {
306+
latestTokenName = s.Parent().Parent().Find(".flex-item-title").Text()
307+
latestTokenID = id
308+
}
309+
})
310+
311+
return latestTokenName, latestTokenID
312+
}
313+
250314
func TestGetUserRss(t *testing.T) {
251315
defer tests.PrepareTestEnv(t)()
252316

0 commit comments

Comments
 (0)