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:
parent
9dea54a9d6
commit
30982b9e7b
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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"}}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)()
|
||||
|
||||
|
|
Loading…
Reference in New Issue