loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Dmitrii Sharshakov
Dmitrii Sharshakov
and committed by
Gusted
30982b9e 9dea54a9

+176 -7
+32 -2
models/auth/access_token.go
··· 98 98 99 99 // NewAccessToken creates new access token. 100 100 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 { 101 110 salt, err := util.CryptoRandomString(10) 102 111 if err != nil { 103 112 return err ··· 110 119 t.Token = hex.EncodeToString(token) 111 120 t.TokenHash = HashToken(t.Token, t.TokenSalt) 112 121 t.TokenLastEight = t.Token[len(t.Token)-8:] 113 - _, err = db.GetEngine(ctx).Insert(t) 114 - return err 122 + return nil 115 123 } 116 124 117 125 // DisplayPublicOnly whether to display this as a public-only token. ··· 234 242 } 235 243 return nil 236 244 } 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 + }
+25
models/auth/access_token_test.go
··· 131 131 require.Error(t, err) 132 132 assert.True(t, auth_model.IsErrAccessTokenNotExist(err)) 133 133 } 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 + }
+4
options/locale/locale_en-US.ini
··· 943 943 access_token_deletion = Delete access token 944 944 access_token_deletion_desc = Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue? 945 945 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. 946 950 repo_and_org_access = Repository and Organization Access 947 951 permissions_public_only = Public only 948 952 permissions_access_all = All (public, private, and limited)
+18
routers/web/user/setting/applications.go
··· 10 10 auth_model "code.gitea.io/gitea/models/auth" 11 11 "code.gitea.io/gitea/models/db" 12 12 "code.gitea.io/gitea/modules/base" 13 + "code.gitea.io/gitea/modules/log" 13 14 "code.gitea.io/gitea/modules/setting" 14 15 "code.gitea.io/gitea/modules/web" 15 16 "code.gitea.io/gitea/services/context" ··· 82 83 ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) 83 84 } else { 84 85 ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) 86 + } 87 + 88 + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") 89 + } 90 + 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) 85 103 } 86 104 87 105 ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
+1
routers/web/web.go
··· 586 586 m.Combo("").Get(user_setting.Applications). 587 587 Post(web.Bind(forms.NewAccessTokenForm{}), user_setting.ApplicationsPost) 588 588 m.Post("/delete", user_setting.DeleteApplication) 589 + m.Post("/regenerate", user_setting.RegenerateApplication) 589 590 }) 590 591 591 592 m.Combo("/keys").Get(user_setting.Keys).
+15
templates/user/settings/applications.tmpl
··· 40 40 </div> 41 41 </div> 42 42 <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> 43 47 <button class="ui red tiny button delete-button" data-modal-id="delete-token" data-url="{{$.Link}}/delete" data-id="{{.ID}}"> 44 48 {{svg "octicon-trash" 16 "tw-mr-1"}} 45 49 {{ctx.Locale.Tr "settings.delete_token"}} ··· 98 102 {{template "user/settings/applications_oauth2" .}} 99 103 {{end}} 100 104 </div> 105 + 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> 101 116 102 117 <div class="ui g-modal-confirm delete modal" id="delete-token"> 103 118 <div class="header">
+17 -5
tests/integration/integration_test.go
··· 421 421 // but without the "scope_" prefix. 422 422 func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.AccessTokenScope) string { 423 423 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) { 425 433 req := NewRequest(t, "GET", "/user/settings/applications") 426 434 resp := session.MakeRequest(t, req, http.StatusOK) 427 435 var csrf string ··· 439 447 assert.NotEmpty(t, csrf) 440 448 urlValues := url.Values{} 441 449 urlValues.Add("_csrf", csrf) 442 - urlValues.Add("name", fmt.Sprintf("api-testing-token-%d", atomic.AddInt64(&tokenCounter, 1))) 450 + urlValues.Add("name", name) 443 451 for _, scope := range scopes { 444 452 urlValues.Add("scope", string(scope)) 445 453 } ··· 458 466 } 459 467 } 460 468 } 469 + } 461 470 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) 464 476 htmlDoc := NewHTMLParser(t, resp.Body) 465 - token = htmlDoc.doc.Find(".ui.info p").Text() 477 + token := htmlDoc.doc.Find(".ui.info p").Text() 466 478 assert.NotEmpty(t, token) 467 479 return token 468 480 }
+64
tests/integration/user_test.go
··· 30 30 "code.gitea.io/gitea/services/mailer" 31 31 "code.gitea.io/gitea/tests" 32 32 33 + "github.com/PuerkitoBio/goquery" 33 34 "github.com/pquerna/otp/totp" 34 35 "github.com/stretchr/testify/assert" 35 36 "github.com/stretchr/testify/require" ··· 245 246 resp := session.MakeRequest(t, req, http.StatusOK) 246 247 // t.Log(resp.Body.String()) 247 248 assert.Equal(t, expected, resp.Body.String()) 249 + } 250 + 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 248 312 } 249 313 250 314 func TestGetUserRss(t *testing.T) {