mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-29 18:30:54 +00:00
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:
@@ -212,7 +212,7 @@ func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/user1/repo1/actions", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(resp, req), nil, nil)
|
||||
ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(t, resp, req), nil, nil)
|
||||
ctx.Repo.Repository = &repo_model.Repository{
|
||||
OwnerName: "user1",
|
||||
Name: "repo1",
|
||||
|
||||
@@ -163,7 +163,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && !ctx.Doer.IsGiteaActions() {
|
||||
if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && ctx.Doer.IsIndividual() {
|
||||
_, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID)
|
||||
if err == nil {
|
||||
// TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented
|
||||
@@ -252,7 +252,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
|
||||
var environ []string
|
||||
if !isPull {
|
||||
// if not "pull", then must be "push", and doer must exist
|
||||
environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
@@ -17,23 +19,20 @@ import (
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
// DeployKeys render the deploy-keys list of a repository page
|
||||
func DeployKeys(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + " / " + ctx.Tr("secrets.secrets")
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys")
|
||||
ctx.Data["PageIsSettingsKeys"] = true
|
||||
ctx.Data["DisableSSH"] = setting.SSH.Disabled
|
||||
|
||||
keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
|
||||
keys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
|
||||
if err != nil {
|
||||
ctx.ServerError("ListDeployKeys", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["RepoDeployKeys"] = keys
|
||||
|
||||
ctx.HTML(http.StatusOK, tplDeployKeys)
|
||||
}
|
||||
|
||||
// DeployKeysPost response for adding a deploy-key of a repository
|
||||
func DeployKeysPost(ctx *context.Context) {
|
||||
form := context.GetFetchActionForm[*forms.AddKeyForm](ctx)
|
||||
if form == nil {
|
||||
@@ -54,16 +53,14 @@ func DeployKeysPost(ctx *context.Context) {
|
||||
}
|
||||
|
||||
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
|
||||
key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
key, err := deploykey_model.AddDeployKeySSH(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
if err != nil {
|
||||
switch {
|
||||
case asymkey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
case deploykey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_been_used"), "content")
|
||||
case asymkey_model.IsErrKeyAlreadyExist(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("settings.ssh_key_been_used"), "content")
|
||||
case asymkey_model.IsErrKeyNameAlreadyUsed(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
|
||||
case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
case asymkey_model.IsErrKeyNameAlreadyUsed(err), deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
|
||||
default:
|
||||
ctx.ServerError("AddDeployKey", err)
|
||||
@@ -75,12 +72,50 @@ func DeployKeysPost(ctx *context.Context) {
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
// DeleteDeployKey response for deleting a deploy-key
|
||||
func DeleteDeployKey(ctx *context.Context) {
|
||||
if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil {
|
||||
key, err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id"))
|
||||
if err != nil && !deploykey_model.IsErrDeployKeyNotExist(err) { // a key that is already gone leaves the caller with the state it asked for
|
||||
ctx.ServerError("DeleteDeployKey", err)
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success"))
|
||||
return
|
||||
}
|
||||
if key != nil {
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success", key.Name))
|
||||
}
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
func DeployKeyGenerateToken(ctx *context.Context) {
|
||||
form := context.GetFetchActionForm[*forms.AddDeployTokenForm](ctx)
|
||||
if form == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
|
||||
key, err := deploykey_model.AddDeployKeyToken(ctx, ctx.Repo.Repository.ID, form.Title, accessMode)
|
||||
if err != nil {
|
||||
if deploykey_model.IsErrDeployKeyNameAlreadyUsed(err) {
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
|
||||
} else {
|
||||
ctx.ServerError("AddDeployToken", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.generate_deploy_token_success", htmlutil.HTMLFormat("<code>%s</code>", key.Token)))
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
func DeployKeyRegenerateToken(ctx *context.Context) {
|
||||
key, err := deploykey_model.RegenerateDeployKeyToken(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id"))
|
||||
if err != nil {
|
||||
if deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
ctx.JSONErrorNotFound()
|
||||
} else {
|
||||
ctx.ServerError("RegenerateDeployToken", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.regenerate_deploy_token_success", htmlutil.HTMLFormat("<code>%s</code>", key.Token)))
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
@@ -38,7 +38,7 @@ func TestAddDeployKey(t *testing.T) {
|
||||
contexttest.LoadRepo(t, ctx, 2)
|
||||
DeployKeysPost(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
|
||||
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
|
||||
})
|
||||
t.Run("ReadWrite", func(t *testing.T) {
|
||||
const testKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9\n"
|
||||
@@ -47,7 +47,7 @@ func TestAddDeployKey(t *testing.T) {
|
||||
contexttest.LoadRepo(t, ctx, 2)
|
||||
DeployKeysPost(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
|
||||
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -94,12 +94,14 @@ func optionsCorsHandler() func(next http.Handler) http.Handler {
|
||||
type AuthMiddleware struct {
|
||||
AllowOAuth2 types.PreMiddlewareProvider
|
||||
AllowBasic types.PreMiddlewareProvider
|
||||
AllowDeployToken types.PreMiddlewareProvider
|
||||
MiddlewareHandler func(*context.Context)
|
||||
}
|
||||
|
||||
func newWebAuthMiddleware() *AuthMiddleware {
|
||||
type keyAllowOAuth2 struct{}
|
||||
type keyAllowBasic struct{}
|
||||
type keyAllowDeployToken struct{}
|
||||
webAuth := &AuthMiddleware{}
|
||||
|
||||
middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider {
|
||||
@@ -114,11 +116,13 @@ func newWebAuthMiddleware() *AuthMiddleware {
|
||||
|
||||
webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true)
|
||||
webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true)
|
||||
webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}, true)
|
||||
|
||||
enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext())
|
||||
webAuth.MiddlewareHandler = func(ctx *context.Context) {
|
||||
allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true
|
||||
allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true
|
||||
allowDeployToken := ctx.GetContextValue(keyAllowDeployToken{}) == true
|
||||
|
||||
group := auth_service.NewGroup()
|
||||
|
||||
@@ -127,13 +131,16 @@ func newWebAuthMiddleware() *AuthMiddleware {
|
||||
if allowOAuth2 {
|
||||
group.Add(&auth_service.OAuth2{})
|
||||
}
|
||||
if allowDeployToken {
|
||||
group.Add(&auth_service.DeployToken{}) // before Basic, which would try the token as a password
|
||||
}
|
||||
if allowBasic {
|
||||
group.Add(&auth_service.Basic{})
|
||||
}
|
||||
|
||||
// Sessionless means the route's auth can be done without web ui, then it doesn't need to create a session
|
||||
// For example: accessing git via http, access rss feeds, downloading attachments, etc
|
||||
isSessionless := allowOAuth2 || allowBasic
|
||||
isSessionless := allowOAuth2 || allowBasic || allowDeployToken
|
||||
|
||||
if setting.Service.EnableReverseProxyAuth {
|
||||
// reverse-proxy should before Session, otherwise the header will be ignored if user has login
|
||||
@@ -1223,6 +1230,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Group("/keys", func() {
|
||||
m.Combo("").Get(repo_setting.DeployKeys).
|
||||
Post(repo_setting.DeployKeysPost)
|
||||
m.Post("/generate-token", repo_setting.DeployKeyGenerateToken)
|
||||
m.Post("/regenerate-token", repo_setting.DeployKeyRegenerateToken)
|
||||
m.Post("/delete", repo_setting.DeleteDeployKey)
|
||||
})
|
||||
|
||||
@@ -1743,12 +1752,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
|
||||
// git lfs uses its own jwt key, and it handles the token & auth by itself, it conflicts with the general "OAuth2" auth method
|
||||
// pattern: "/{username}/{reponame}/{lfs-paths}": git-lfs support, see also addOwnerRepoGitHTTPRouters
|
||||
common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, repo.CorsHandler(), optSignInFromAnyOrigin)
|
||||
common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin)
|
||||
|
||||
// Some users want to use "web-based git client" to access Gitea's repositories,
|
||||
// so the CORS handler and OPTIONS method are used.
|
||||
// pattern: "/{username}/{reponame}/{git-paths}": git http support
|
||||
addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb())
|
||||
addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb())
|
||||
|
||||
m.Group("/notifications", func() {
|
||||
m.Get("", user.Notifications)
|
||||
|
||||
Reference in New Issue
Block a user