feat: add deploy tokens (#37306)

Deploy keys only work over SSH. A deploy token is their counterpart for HTTPS: a repository scoped credential, used as the password of a Git request, with read or read and write access. It covers Git operations and LFS, and can be regenerated in place.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Mythos <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
ToastyTheBot
2026-08-27 03:32:44 +08:00
committed by GitHub
parent 3c4d5a6a5c
commit 646ea0f253
76 changed files with 1594 additions and 831 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ func (input *notifyInput) Notify(ctx context.Context) {
func notify(ctx context.Context, input *notifyInput) error {
shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch
if input.Doer.IsGiteaActions() {
if input.Doer.ID == user_model.ActionsUserID {
// avoiding triggering cyclically, for example:
// a comment of an issue will trigger the runner to add a new comment as reply,
// and the new comment will trigger the runner again.
+13 -6
View File
@@ -60,8 +60,18 @@ func GetAgitBranchInfo(ctx context.Context, repoID int64, baseBranchName string)
return "", "", util.NewNotExistErrorf("base branch does not exist")
}
type ProcReceiveOptions struct {
OldCommitIDs []string
NewCommitIDs []string
RefFullNames []git.RefName
GitPushOptions private.GitPushOptions
Doer *user_model.User
}
// ProcReceive handle proc receive work
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) {
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *ProcReceiveOptions) ([]private.HookProcReceiveRefResult, error) {
results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush)
topicBranch := opts.GitPushOptions["topic"]
@@ -72,12 +82,9 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
description := parseAgitPushOptionValue(opts.GitPushOptions["description"])
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
userName := strings.ToLower(opts.UserName)
pusher, err := user_model.GetUserByID(ctx, opts.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get user. Error: %w", err)
}
pusher := opts.Doer
userName := strings.ToLower(pusher.Name)
for i := range opts.OldCommitIDs {
if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() {
+23 -17
View File
@@ -9,12 +9,13 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
repo_model "gitea.dev/models/repo"
)
// DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside
func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) {
deployKeys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: repoID})
deployKeys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: repoID})
if err != nil {
return 0, fmt.Errorf("listDeployKeys: %w", err)
}
@@ -28,13 +29,17 @@ func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) {
}
// deleteDeployKeyFromDB delete deploy keys from database
func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) error {
if _, err := db.DeleteByID[asymkey_model.DeployKey](ctx, key.ID); err != nil {
func deleteDeployKeyFromDB(ctx context.Context, key *deploykey_model.DeployKey) error {
if _, err := db.DeleteByID[deploykey_model.DeployKey](ctx, key.ID); err != nil {
return fmt.Errorf("delete deploy key [%d]: %w", key.ID, err)
}
if key.KeyType == deploykey_model.KeyTypeToken { // a token has no public key to clean up
return nil
}
// Check if this is the last reference to same key content.
has, err := asymkey_model.IsDeployKeyExistByPublicKeyID(ctx, key.KeyID)
has, err := deploykey_model.IsDeployKeyExistByPublicKeyID(ctx, key.KeyID)
if err != nil {
return err
} else if !has {
@@ -46,21 +51,22 @@ func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) er
return nil
}
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed.
// Permissions check should be done outside.
func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
key, err := asymkey_model.GetDeployKeyByID(ctx, repo.ID, id)
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed,
// and returns the key it deleted. Permissions check should be done outside.
func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) (*deploykey_model.DeployKey, error) {
deleted, err := db.WithTx2(ctx, func(ctx context.Context) (*deploykey_model.DeployKey, error) {
key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, id)
if err != nil {
if asymkey_model.IsErrDeployKeyNotExist(err) {
return nil
}
return fmt.Errorf("GetDeployKeyByID: %w", err)
return nil, err
}
return deleteDeployKeyFromDB(ctx, key)
}); err != nil {
return err
return key, deleteDeployKeyFromDB(ctx, key)
})
if err != nil {
return nil, err
}
if deleted.KeyType == deploykey_model.KeyTypeToken {
return deleted, nil // a token never appears in the authorized_keys file
}
return RewriteAllPublicKeys(ctx)
return deleted, RewriteAllPublicKeys(ctx)
}
+4 -3
View File
@@ -29,6 +29,7 @@ const (
AccessTokenMethodName = "access_token"
OAuth2TokenMethodName = "oauth2_token"
ActionTokenMethodName = "action_token"
DeployTokenMethodName = "deploy_token"
)
// Basic implements the Auth interface and authenticates requests (API requests
@@ -41,7 +42,7 @@ func (b *Basic) Name() string {
return BasicMethodName
}
func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
func parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return ret
@@ -53,7 +54,7 @@ func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname,
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
// Check if username or password is a token
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
isUsernameToken := passwd == "" || passwd == "x-oauth-basic"
// Assume username is token
authToken := uname
if !isUsernameToken {
@@ -122,7 +123,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
// name/token on successful validation.
// Returns nil if header is empty or validation fails.
func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
parseBasicRet := b.parseAuthBasic(req)
parseBasicRet := parseAuthBasic(req)
authToken, uname, passwd := parseBasicRet.authToken, parseBasicRet.uname, parseBasicRet.passwd
if authToken == "" && uname == "" {
return nil, nil //nolint:nilnil // the auth method is not applicable
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
deploykey_model "gitea.dev/models/deploykey"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
)
var _ Method = &DeployToken{}
// DeployToken authenticates a deploy key token given as HTTP basic auth credential.
// Only add it to an auth group where a repo scoped credential makes sense.
type DeployToken struct{}
func (d *DeployToken) Name() string {
return DeployTokenMethodName
}
// Verify returns a user that stands for the deploy key alone. Its permissions come from the key,
// see access_model.getDeployKeyRepoPermission, so the request can never reach another repository
// or exceed the access mode of the key.
func (d *DeployToken) Verify(req *http.Request, _ http.ResponseWriter, store DataStore, _ SessionStore) (*user_model.User, error) {
authToken := parseAuthBasic(req).authToken
if authToken == "" {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
key, err := deploykey_model.VerifyDeployKeyToken(req.Context(), authToken)
if err != nil {
if deploykey_model.IsErrDeployKeyNotExist(err) {
return nil, nil //nolint:nilnil // not a deploy token, let the other methods try
}
return nil, err
}
if err := deploykey_model.UpdateDeployKeyLastUsed(req.Context(), key.ID); err != nil {
log.Error("UpdateDeployKeyUpdated: %v", err)
}
store.GetData()["LoginMethod"] = DeployTokenMethodName
return user_model.NewDeployKeyUserWithKeyID(key.ID), nil
}
+2 -2
View File
@@ -225,11 +225,11 @@ func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
return b
}
func NewBaseContextForTest(resp http.ResponseWriter, req *http.Request) *Base {
func NewBaseContextForTest(t reqctx.TestingT, resp http.ResponseWriter, req *http.Request) *Base {
if !setting.IsInTesting {
panic("This function is only for testing")
}
ctx := reqctx.NewRequestContextForTest(req.Context())
ctx := reqctx.NewRequestContextForTest(t)
*req = *req.WithContext(ctx)
return NewBaseContext(resp, req)
}
+2 -2
View File
@@ -29,7 +29,7 @@ func TestRedirect(t *testing.T) {
}
for _, c := range cases {
resp := httptest.NewRecorder()
b := NewBaseContextForTest(resp, req)
b := NewBaseContextForTest(t, resp, req)
resp.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "dummy"}).String())
b.Redirect(c.url)
has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy"
@@ -39,7 +39,7 @@ func TestRedirect(t *testing.T) {
req, _ = http.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder()
req.Header.Add("X-Gitea-Fetch-Action", "1")
b := NewBaseContextForTest(resp, req)
b := NewBaseContextForTest(t, resp, req)
b.Redirect("/other")
assert.Contains(t, resp.Header().Get("Content-Type"), "application/json")
assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String())
+2 -2
View File
@@ -42,7 +42,7 @@ func TestRedirectToCurrentSite(t *testing.T) {
t.Run(c.location, func(t *testing.T) {
req := &http.Request{URL: &url.URL{Path: "/"}}
resp := httptest.NewRecorder()
base := NewBaseContextForTest(resp, req)
base := NewBaseContextForTest(t, resp, req)
ctx := NewWebContext(base, nil, nil)
ctx.RedirectToCurrentSite(c.location)
redirect := test.RedirectURL(resp)
@@ -58,7 +58,7 @@ func TestAppFullLink(t *testing.T) {
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(req.Context()), req)
tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(t), req)
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
+2
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"time"
user_model "gitea.dev/models/user"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/private"
@@ -23,6 +24,7 @@ type PrivateContext struct {
*Base
Override context.Context
Doer *user_model.User
Repo *Repository
}
+1 -1
View File
@@ -42,7 +42,7 @@ func mockRequest(t *testing.T, reqPath string) *http.Request {
requestURL, err := url.Parse(path)
assert.NoError(t, err)
req := &http.Request{Method: method, Host: requestURL.Host, URL: requestURL, Form: maps.Clone(requestURL.Query()), Header: http.Header{}}
req = req.WithContext(reqctx.NewRequestContextForTest(req.Context()))
req = req.WithContext(reqctx.NewRequestContextForTest(t))
return req
}
+13 -10
View File
@@ -20,6 +20,7 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/auth"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
@@ -846,19 +847,21 @@ func ToGitHook(h *git.Hook) *api.GitHook {
}
}
// ToDeployKey convert asymkey_model.DeployKey to api.DeployKey
func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *asymkey_model.DeployKey) *api.DeployKey {
// ToDeployKey convert deploykey_model.DeployKey to api.DeployKey
func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *deploykey_model.DeployKey) *api.DeployKey {
k := &api.DeployKey{
ID: deployKey.ID,
KeyID: deployKey.KeyID,
URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID),
Title: deployKey.Name,
Created: deployKey.CreatedUnix.AsTime(),
ReadOnly: deployKey.Mode == perm.AccessModeRead, // All deploy keys are read-only.
ID: deployKey.ID,
KeyType: util.Iif(deployKey.KeyType == deploykey_model.KeyTypeSSH, "ssh", "token"),
KeyID: deployKey.KeyID,
Token: deployKey.Token,
URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID),
Title: deployKey.Name,
Fingerprint: deployKey.Fingerprint,
Created: deployKey.CreatedUnix.AsTime(),
ReadOnly: deployKey.IsReadOnly(),
}
if err := deployKey.LoadPublicKey(ctx); err == nil {
if deployKey.KeyType == deploykey_model.KeyTypeSSH && deployKey.LoadPublicKey(ctx) == nil {
k.Key = deployKey.PublicKey.Content
k.Fingerprint = deployKey.PublicKey.Fingerprint
}
return k
}
+7
View File
@@ -576,3 +576,10 @@ type SaveTopicForm struct {
middleware.FormDefaultValidator
Topics []string `binding:"topics;Required;"`
}
// AddDeployTokenForm form for adding a deploy token to a repository
type AddDeployTokenForm struct {
middleware.FormDefaultValidator
Title string `binding:"Required;MaxSize(50)"`
IsWritable bool
}
+14 -20
View File
@@ -49,16 +49,18 @@ type requestContext struct {
// Claims is a JWT Token Claims
type Claims struct {
RepoID int64
Op string
UserID int64
RepoID int64
Op string
UserID int64
UserExtDoerData string
jwt.RegisteredClaims
}
type AuthTokenOptions struct {
Op string
UserID int64
RepoID int64
Op string
UserID int64
UserExtDoerData string
RepoID int64
}
func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
@@ -68,9 +70,10 @@ func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
NotBefore: jwt.NewNumericDate(now),
},
RepoID: opts.RepoID,
Op: opts.Op,
UserID: opts.UserID,
RepoID: opts.RepoID,
Op: opts.Op,
UserID: opts.UserID,
UserExtDoerData: opts.UserExtDoerData,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -544,15 +547,6 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
accessMode = perm_model.AccessModeWrite
}
if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok {
perm, err := access_model.GetActionsUserRepoPermission(ctx, repository, ctx.Doer, taskID)
if err != nil {
log.Error("Unable to GetActionsUserRepoPermission for task[%d] Error: %v", taskID, err)
return false
}
return perm.CanAccess(accessMode, unit.TypeCode)
}
// it works for both anonymous request and signed-in user, then perm.CanAccess will do the permission check
perm, err := access_model.GetDoerRepoPermission(ctx, repository, ctx.Doer)
if err != nil {
@@ -604,9 +598,9 @@ func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repo
return nil, errors.New("invalid token claim")
}
u, err := user_model.GetUserByID(ctx, claims.UserID)
u, err := user_model.GetDoerUser(ctx, claims.UserID, claims.UserExtDoerData)
if err != nil {
log.Error("Unable to GetUserById[%d]: Error: %v", claims.UserID, err)
log.Error("Unable to GetDoerUser[%d]: Error: %v", claims.UserID, err)
return nil, err
}
if !u.IsActive || u.ProhibitLogin {
+20
View File
@@ -8,6 +8,7 @@ import (
"testing"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
@@ -101,4 +102,23 @@ func TestAuthenticate(t *testing.T) {
err := handleLFSTokenTestPerm("upload", 2, repo1, perm_model.AccessModeWrite)
assert.NoError(t, err)
})
// a deploy-key doer has no user row, so the token must carry its ext doer data to stay redeemable
t.Run("handleLFSToken resolves deploy-key doers", func(t *testing.T) {
key, err := deploykey_model.AddDeployKeyToken(t.Context(), repo1.ID, "lfs", perm_model.AccessModeRead)
require.NoError(t, err)
doer := user_model.NewDeployKeyUserWithKeyID(key.ID)
getDoerToken := func(op string) string {
s, _ := GetLFSAuthTokenWithBearer(AuthTokenOptions{Op: op, UserID: doer.ID, UserExtDoerData: doer.ExtDoerData.EncodeToString(), RepoID: repo1.ID})
_, token, _ := strings.Cut(s, " ")
return token
}
u, err := handleLFSToken(ctx, getDoerToken("download"), repo1, perm_model.AccessModeRead)
require.NoError(t, err)
assert.Equal(t, user_model.DeployKeyUserID, u.ID)
_, err = handleLFSToken(ctx, getDoerToken("upload"), repo1, perm_model.AccessModeWrite)
assert.ErrorContains(t, err, "no permission to access the repository")
})
}
+1 -1
View File
@@ -38,7 +38,7 @@ func TestRenderHelperMention(t *testing.T) {
// when using web context, use user.IsUserVisibleToViewer to check
req, err := http.NewRequest(http.MethodGet, "/", nil)
assert.NoError(t, err)
base := gitea_context.NewBaseContextForTest(httptest.NewRecorder(), req)
base := gitea_context.NewBaseContextForTest(t, httptest.NewRecorder(), req)
giteaCtx := gitea_context.NewWebContext(base, &contexttest.MockRender{}, nil)
assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic))
+4 -14
View File
@@ -96,13 +96,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
if opts.RefFullName.IsTag() {
if pusher == nil || pusher.ID != opts.PusherID {
if opts.PusherID == user_model.ActionsUserID {
pusher = user_model.NewActionsUser()
} else {
var err error
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
return err
}
if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
return err
}
}
tagName := opts.RefFullName.TagName()
@@ -143,13 +138,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
}
} else if opts.RefFullName.IsBranch() {
if pusher == nil || pusher.ID != opts.PusherID {
if opts.PusherID == user_model.ActionsUserID {
pusher = user_model.NewActionsUser()
} else {
var err error
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
return err
}
if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
return err
}
}