mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-03-09 16:02:06 +01:00
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
8 changed files with 176 additions and 7 deletions
|
@ -98,6 +98,15 @@ func init() {
|
||||||
|
|
||||||
// NewAccessToken creates new access token.
|
// NewAccessToken creates new access token.
|
||||||
func NewAccessToken(ctx context.Context, t *AccessToken) error {
|
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)
|
salt, err := util.CryptoRandomString(10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -110,8 +119,7 @@ func NewAccessToken(ctx context.Context, t *AccessToken) error {
|
||||||
t.Token = hex.EncodeToString(token)
|
t.Token = hex.EncodeToString(token)
|
||||||
t.TokenHash = HashToken(t.Token, t.TokenSalt)
|
t.TokenHash = HashToken(t.Token, t.TokenSalt)
|
||||||
t.TokenLastEight = t.Token[len(t.Token)-8:]
|
t.TokenLastEight = t.Token[len(t.Token)-8:]
|
||||||
_, err = db.GetEngine(ctx).Insert(t)
|
return nil
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisplayPublicOnly whether to display this as a public-only token.
|
// 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
|
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)
|
require.Error(t, err)
|
||||||
assert.True(t, auth_model.IsErrAccessTokenNotExist(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 = 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?
|
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.
|
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
|
repo_and_org_access = Repository and Organization Access
|
||||||
permissions_public_only = Public only
|
permissions_public_only = Public only
|
||||||
permissions_access_all = All (public, private, and limited)
|
permissions_access_all = All (public, private, and limited)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/modules/base"
|
"code.gitea.io/gitea/modules/base"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
|
@ -87,6 +88,23 @@ func DeleteApplication(ctx *context.Context) {
|
||||||
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
|
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) {
|
func loadApplicationsData(ctx *context.Context) {
|
||||||
ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly
|
ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly
|
||||||
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID})
|
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).
|
m.Combo("").Get(user_setting.Applications).
|
||||||
Post(web.Bind(forms.NewAccessTokenForm{}), user_setting.ApplicationsPost)
|
Post(web.Bind(forms.NewAccessTokenForm{}), user_setting.ApplicationsPost)
|
||||||
m.Post("/delete", user_setting.DeleteApplication)
|
m.Post("/delete", user_setting.DeleteApplication)
|
||||||
|
m.Post("/regenerate", user_setting.RegenerateApplication)
|
||||||
})
|
})
|
||||||
|
|
||||||
m.Combo("/keys").Get(user_setting.Keys).
|
m.Combo("/keys").Get(user_setting.Keys).
|
||||||
|
|
|
@ -40,6 +40,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-item-trailing">
|
<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}}">
|
<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"}}
|
{{svg "octicon-trash" 16 "tw-mr-1"}}
|
||||||
{{ctx.Locale.Tr "settings.delete_token"}}
|
{{ctx.Locale.Tr "settings.delete_token"}}
|
||||||
|
@ -99,6 +103,17 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</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="ui g-modal-confirm delete modal" id="delete-token">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
{{svg "octicon-trash"}}
|
{{svg "octicon-trash"}}
|
||||||
|
|
|
@ -421,7 +421,15 @@ var tokenCounter int64
|
||||||
// but without the "scope_" prefix.
|
// but without the "scope_" prefix.
|
||||||
func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.AccessTokenScope) string {
|
func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.AccessTokenScope) string {
|
||||||
t.Helper()
|
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")
|
req := NewRequest(t, "GET", "/user/settings/applications")
|
||||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
var csrf string
|
var csrf string
|
||||||
|
@ -439,7 +447,7 @@ func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.
|
||||||
assert.NotEmpty(t, csrf)
|
assert.NotEmpty(t, csrf)
|
||||||
urlValues := url.Values{}
|
urlValues := url.Values{}
|
||||||
urlValues.Add("_csrf", csrf)
|
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 {
|
for _, scope := range scopes {
|
||||||
urlValues.Add("scope", string(scope))
|
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")
|
// assertAccessToken retrieves a token from "/user/settings/applications" and returns it.
|
||||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
// 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)
|
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)
|
assert.NotEmpty(t, token)
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ import (
|
||||||
"code.gitea.io/gitea/services/mailer"
|
"code.gitea.io/gitea/services/mailer"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
@ -247,6 +248,69 @@ func testExportUserGPGKeys(t *testing.T, user, expected string) {
|
||||||
assert.Equal(t, expected, resp.Body.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) {
|
func TestGetUserRss(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue