{{ctx.Locale.Tr "repo.settings.deploy_key_deletion_desc"}}
diff --git a/models/asymkey/error.go b/models/asymkey/error.go index 5df7beb8cd..6c0db1dc7c 100644 --- a/models/asymkey/error.go +++ b/models/asymkey/error.go @@ -276,6 +276,48 @@ func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error { return util.ErrNotExist } +// ErrHTTPSDeployKeyNotExist is returned when a lookup of an HTTPS deploy key +// fails. Either of ID or RepoID may be zero depending on the call site. +type ErrHTTPSDeployKeyNotExist struct { + ID int64 + RepoID int64 +} + +// IsErrHTTPSDeployKeyNotExist checks if an error is an ErrHTTPSDeployKeyNotExist. +func IsErrHTTPSDeployKeyNotExist(err error) bool { + _, ok := err.(ErrHTTPSDeployKeyNotExist) + return ok +} + +func (err ErrHTTPSDeployKeyNotExist) Error() string { + return fmt.Sprintf("HTTPS deploy key does not exist [id: %d, repo_id: %d]", err.ID, err.RepoID) +} + +func (err ErrHTTPSDeployKeyNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrHTTPSDeployKeyNameAlreadyUsed is returned when creating an HTTPS deploy +// key would collide with an existing name under the same repository. +type ErrHTTPSDeployKeyNameAlreadyUsed struct { + RepoID int64 + Name string +} + +// IsErrHTTPSDeployKeyNameAlreadyUsed checks if an error is an ErrHTTPSDeployKeyNameAlreadyUsed. +func IsErrHTTPSDeployKeyNameAlreadyUsed(err error) bool { + _, ok := err.(ErrHTTPSDeployKeyNameAlreadyUsed) + return ok +} + +func (err ErrHTTPSDeployKeyNameAlreadyUsed) Error() string { + return fmt.Sprintf("HTTPS deploy key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name) +} + +func (err ErrHTTPSDeployKeyNameAlreadyUsed) Unwrap() error { + return util.ErrAlreadyExist +} + // ErrSSHInvalidTokenSignature represents a "ErrSSHInvalidTokenSignature" kind of error. type ErrSSHInvalidTokenSignature struct { Wrapped error diff --git a/models/asymkey/https_deploy_key.go b/models/asymkey/https_deploy_key.go new file mode 100644 index 0000000000..581df72369 --- /dev/null +++ b/models/asymkey/https_deploy_key.go @@ -0,0 +1,183 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "crypto/subtle" + "encoding/hex" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// HTTPSDeployKey is a per-repository credential that authenticates Git +// operations over HTTPS without being tied to a user account. It mirrors the +// semantics of the SSH DeployKey (RepoID + Mode) but carries a hashed bearer +// token instead of a public-key fingerprint. +type HTTPSDeployKey struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + Name string `xorm:"UNIQUE(s) NOT NULL"` + TokenHash string `xorm:"UNIQUE NOT NULL"` + TokenSalt string `xorm:"NOT NULL"` + TokenLastEight string `xorm:"INDEX"` + Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"` + + // Token holds the plaintext token only on the row returned from a creation + // call. It is never read from or written to the database. + Token string `xorm:"-"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + HasRecentActivity bool `xorm:"-"` + HasUsed bool `xorm:"-"` +} + +// AfterLoad populates derived display fields after XORM reads a row. +func (k *HTTPSDeployKey) AfterLoad() { + k.HasUsed = k.UpdatedUnix > k.CreatedUnix + k.HasRecentActivity = k.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow() +} + +// IsReadOnly reports whether the key grants only read access to its +// repository. +func (k *HTTPSDeployKey) IsReadOnly() bool { + return k.Mode == perm.AccessModeRead +} + +func init() { + db.RegisterModel(new(HTTPSDeployKey)) +} + +// tokenIsValidFormat reports whether s looks like a serialized deploy token +// (40 lowercase hex chars). We reject everything else early so that an +// incidental basic-auth password can never collide with the token lookup. +func tokenIsValidFormat(s string) bool { + if len(s) != 40 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +// AddHTTPSDeployKey creates a new HTTPS deploy key for the given repository +// and returns both the stored row and the plaintext token. The plaintext is +// only returned here; callers must surface it to the user exactly once. +func AddHTTPSDeployKey(ctx context.Context, repoID int64, name string, readOnly bool) (*HTTPSDeployKey, string, error) { + if name == "" { + return nil, "", util.NewInvalidArgumentErrorf("deploy key name must not be empty") + } + + has, err := db.GetEngine(ctx).Where("repo_id = ? AND name = ?", repoID, name).Exist(new(HTTPSDeployKey)) + if err != nil { + return nil, "", err + } + if has { + return nil, "", ErrHTTPSDeployKeyNameAlreadyUsed{RepoID: repoID, Name: name} + } + + salt := util.CryptoRandomString(10) + tokenBytes := util.CryptoRandomBytes(20) + token := hex.EncodeToString(tokenBytes) + + mode := perm.AccessModeRead + if !readOnly { + mode = perm.AccessModeWrite + } + + key := &HTTPSDeployKey{ + RepoID: repoID, + Name: name, + TokenHash: auth_model.HashToken(token, salt), + TokenSalt: salt, + TokenLastEight: token[len(token)-8:], + Mode: mode, + } + if err := db.Insert(ctx, key); err != nil { + return nil, "", err + } + + key.Token = token + return key, token, nil +} + +// GetHTTPSDeployKeyByID loads a single HTTPS deploy key by its primary key. +func GetHTTPSDeployKeyByID(ctx context.Context, id int64) (*HTTPSDeployKey, error) { + key, exist, err := db.GetByID[HTTPSDeployKey](ctx, id) + if err != nil { + return nil, err + } + if !exist { + return nil, ErrHTTPSDeployKeyNotExist{ID: id} + } + return key, nil +} + +// ListHTTPSDeployKeysOptions filters a list query. +type ListHTTPSDeployKeysOptions struct { + db.ListOptions + RepoID int64 +} + +// ToConds implements db.FindOptions. +func (opt ListHTTPSDeployKeysOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opt.RepoID != 0 { + cond = cond.And(builder.Eq{"repo_id": opt.RepoID}) + } + return cond +} + +// DeleteHTTPSDeployKey removes the key identified by (repoID, id). The repo +// scope is required so that a caller in one repository cannot drop a token +// belonging to another. +func DeleteHTTPSDeployKey(ctx context.Context, repoID, id int64) error { + cnt, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).ID(id).Delete(new(HTTPSDeployKey)) + if err != nil { + return err + } + if cnt == 0 { + return ErrHTTPSDeployKeyNotExist{ID: id, RepoID: repoID} + } + return nil +} + +// VerifyHTTPSDeployToken returns the key that the given plaintext token +// authenticates, or ErrHTTPSDeployKeyNotExist if no key matches. +func VerifyHTTPSDeployToken(ctx context.Context, token string) (*HTTPSDeployKey, error) { + if !tokenIsValidFormat(token) { + return nil, ErrHTTPSDeployKeyNotExist{} + } + + lastEight := token[len(token)-8:] + var candidates []HTTPSDeployKey + if err := db.GetEngine(ctx).Where("token_last_eight = ?", lastEight).Find(&candidates); err != nil { + return nil, err + } + + for i := range candidates { + expected := auth_model.HashToken(token, candidates[i].TokenSalt) + if subtle.ConstantTimeCompare([]byte(candidates[i].TokenHash), []byte(expected)) == 1 { + k := candidates[i] + k.UpdatedUnix = timeutil.TimeStampNow() + if _, err := db.GetEngine(ctx).ID(k.ID).Cols("updated_unix").Update(&k); err != nil { + return nil, err + } + return &k, nil + } + } + return nil, ErrHTTPSDeployKeyNotExist{} +} diff --git a/models/asymkey/https_deploy_key_test.go b/models/asymkey/https_deploy_key_test.go new file mode 100644 index 0000000000..a18770279c --- /dev/null +++ b/models/asymkey/https_deploy_key_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddHTTPSDeployKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "ci-readonly", true) + require.NoError(t, err) + require.NotNil(t, key) + + assert.Equal(t, int64(1), key.RepoID) + assert.Equal(t, "ci-readonly", key.Name) + assert.True(t, key.IsReadOnly()) + assert.Len(t, token, 40, "token should be a 40-char hex string") + for _, r := range token { + ok := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') + assert.True(t, ok, "token contains non-hex char %q", r) + } + + got, err := GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.NoError(t, err) + assert.Equal(t, key.ID, got.ID) + assert.Equal(t, key.TokenHash, got.TokenHash) + assert.Empty(t, got.Token, "plaintext token must not be persisted") +} + +func TestAddHTTPSDeployKey_NameUnique(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _, _, err := AddHTTPSDeployKey(t.Context(), 1, "dup", false) + require.NoError(t, err) + + _, _, err = AddHTTPSDeployKey(t.Context(), 1, "dup", false) + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNameAlreadyUsed(err), + "expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", err, err) + + // Same name on a different repo is fine. + _, _, err = AddHTTPSDeployKey(t.Context(), 2, "dup", false) + require.NoError(t, err) +} + +func TestListHTTPSDeployKeys(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _, _, err := AddHTTPSDeployKey(t.Context(), 1, "a", true) + require.NoError(t, err) + _, _, err = AddHTTPSDeployKey(t.Context(), 1, "b", false) + require.NoError(t, err) + _, _, err = AddHTTPSDeployKey(t.Context(), 2, "c", true) + require.NoError(t, err) + + keys, err := db.Find[HTTPSDeployKey](t.Context(), + ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + assert.Len(t, keys, 2) + + keys, err = db.Find[HTTPSDeployKey](t.Context(), + ListHTTPSDeployKeysOptions{RepoID: 2}) + require.NoError(t, err) + assert.Len(t, keys, 1) +} + +func TestDeleteHTTPSDeployKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, _, err := AddHTTPSDeployKey(t.Context(), 1, "to-delete", true) + require.NoError(t, err) + + require.NoError(t, DeleteHTTPSDeployKey(t.Context(), 1, key.ID)) + + _, err = GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNotExist(err)) + + // Deleting a key that belongs to a different repo must fail cleanly. + key, _, err = AddHTTPSDeployKey(t.Context(), 1, "stays", true) + require.NoError(t, err) + err = DeleteHTTPSDeployKey(t.Context(), 2, key.ID) + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNotExist(err)) +} + +func TestVerifyHTTPSDeployToken(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "verify", false) + require.NoError(t, err) + + got, err := VerifyHTTPSDeployToken(t.Context(), token) + require.NoError(t, err) + assert.Equal(t, key.ID, got.ID) + assert.Equal(t, key.RepoID, got.RepoID) + + _, err = VerifyHTTPSDeployToken(t.Context(), "0000000000000000000000000000000000000000") + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNotExist(err)) + + _, err = VerifyHTTPSDeployToken(t.Context(), "") + require.Error(t, err) + + _, err = VerifyHTTPSDeployToken(t.Context(), "not-hex") + require.Error(t, err) +} diff --git a/models/asymkey/main_test.go b/models/asymkey/main_test.go index be71f848d9..3613f5bf3c 100644 --- a/models/asymkey/main_test.go +++ b/models/asymkey/main_test.go @@ -15,6 +15,7 @@ func TestMain(m *testing.M) { "gpg_key.yml", "public_key.yml", "deploy_key.yml", + "https_deploy_key.yml", "gpg_key_import.yml", "user.yml", "email_address.yml", diff --git a/models/fixtures/https_deploy_key.yml b/models/fixtures/https_deploy_key.yml new file mode 100644 index 0000000000..0d1b2f0098 --- /dev/null +++ b/models/fixtures/https_deploy_key.yml @@ -0,0 +1,3 @@ +[] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..78b94e25fd 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), + newMigration(332, "Add https_deploy_key table", v1_27.AddHTTPSDeployKeyTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..26794b9cb1 --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "xorm.io/xorm" +) + +// AddHTTPSDeployKeyTable introduces the per-repository HTTPS deploy-key +// credential table. The shape here must stay in lock-step with the +// HTTPSDeployKey struct in models/asymkey. +func AddHTTPSDeployKeyTable(x *xorm.Engine) error { + type HTTPSDeployKey struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + Name string `xorm:"UNIQUE(s) NOT NULL"` + TokenHash string `xorm:"UNIQUE NOT NULL"` + TokenSalt string `xorm:"NOT NULL"` + TokenLastEight string `xorm:"INDEX"` + Mode int `xorm:"NOT NULL DEFAULT 1"` + CreatedUnix int64 `xorm:"INDEX created"` + UpdatedUnix int64 `xorm:"INDEX updated"` + } + return x.Sync(new(HTTPSDeployKey)) +} diff --git a/models/migrations/v1_27/v332_test.go b/models/migrations/v1_27/v332_test.go new file mode 100644 index 0000000000..392bcfa690 --- /dev/null +++ b/models/migrations/v1_27/v332_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/migrationtest" + + "github.com/stretchr/testify/assert" +) + +func Test_AddHTTPSDeployKeyTable(t *testing.T) { + // Start from an empty DB — nothing depends on prior migration state. + x, deferable := migrationtest.PrepareTestEnv(t, 0) + defer deferable() + if x == nil || t.Failed() { + return + } + + if err := AddHTTPSDeployKeyTable(x); err != nil { + assert.NoError(t, err) + return + } + + type HTTPSDeployKey struct { + ID int64 + RepoID int64 + Name string + TokenHash string + TokenSalt string + TokenLastEight string + Mode int + CreatedUnix int64 + UpdatedUnix int64 + } + + _, err := x.Insert(&HTTPSDeployKey{ + RepoID: 1, + Name: "migration-smoke", + TokenHash: "hash", + TokenSalt: "salt", + TokenLastEight: "abcd1234", + Mode: 1, + }) + assert.NoError(t, err) + + count, err := x.Count(&HTTPSDeployKey{}) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 47fcac7ae7..df91e76a78 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2380,6 +2380,11 @@ "repo.settings.deploy_key_deletion": "Remove Deploy Key", "repo.settings.deploy_key_deletion_desc": "Removing a deploy key will revoke its access to this repository. Continue?", "repo.settings.deploy_key_deletion_success": "The deploy key has been removed.", + "repo.settings.https_deploy_keys": "HTTPS Deploy Keys", + "repo.settings.https_deploy_key_desc": "HTTPS deploy keys are per-repository tokens that can be used as the password for Git operations over HTTPS. The token is shown only once, right after it is created.", + "repo.settings.add_https_deploy_key": "Add HTTPS Deploy Key", + "repo.settings.no_https_deploy_keys": "There are no HTTPS deploy keys yet.", + "repo.settings.https_deploy_key_created": "The HTTPS deploy key \"%s\" has been created. Copy the token now — it will not be shown again: %s", "repo.settings.branches": "Branches", "repo.settings.protected_branch": "Branch Protection", "repo.settings.protected_branch.save_rule": "Save Rule", diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index 928c78a61f..8673998f9c 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -157,6 +157,48 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } + // HTTPS deploy tokens authenticate as the repo owner but must be + // constrained to the single bound repository and mode. We do this + // before the other basic-auth checks so a deploy token bypasses + // PAT-scope verification, 2FA, and unit-permission lookup — those + // all assume the doer's own permissions should drive the outcome, + // which is not what a deploy token is supposed to do. + if ctx.Data["IsDeployToken"] == true { + boundRepoID, _ := ctx.Data["DeployTokenRepoID"].(int64) + if !repoExist || boundRepoID != repo.ID { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return nil + } + tokenMode, _ := ctx.Data["DeployTokenMode"].(perm.AccessMode) + if accessMode > tokenMode { + ctx.PlainText(http.StatusForbidden, "Deploy token does not grant write access") + return nil + } + if !isPull && repo.IsMirror { + ctx.PlainText(http.StatusForbidden, "mirror repository is read-only") + return nil + } + // Enforce the per-unit enablement check. Without this a deploy + // token could push to or clone a wiki whose TypeWiki unit has + // been disabled, bypassing the same check applied to the + // user-password and PAT paths further down. + if isWiki { + if _, err := repo.GetUnit(ctx, unit.TypeWiki); err != nil { + if repo_model.IsErrUnitTypeNotExist(err) { + ctx.PlainText(http.StatusForbidden, "repository wiki is disabled") + return nil + } + ctx.ServerError("GetUnit(UnitTypeWiki) for "+repo.FullName(), err) + return nil + } + } + var environ []string + if !isPull { + environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki) + } + return &serviceHandler{serviceType, repo, isWiki, environ} + } + context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode)) if ctx.Written() { return nil diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go index ab54b8ccf5..95a0f91ffe 100644 --- a/routers/web/repo/setting/deploy_key.go +++ b/routers/web/repo/setting/deploy_key.go @@ -29,6 +29,14 @@ func DeployKeys(ctx *context.Context) { } ctx.Data["Deploykeys"] = keys + httpsKeys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) + if err != nil { + ctx.ServerError("ListHTTPSDeployKeys", err) + return + } + ctx.Data["HTTPSDeploykeys"] = httpsKeys + ctx.HTML(http.StatusOK, tplDeployKeys) } @@ -107,3 +115,46 @@ func DeleteDeployKey(ctx *context.Context) { ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") } + +// HTTPSDeployKeysPost handles creation of an HTTPS deploy key for the current +// repository. The plaintext token is surfaced to the user exactly once via +// the flash system. +func HTTPSDeployKeysPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.HTTPSDeployKeyForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + ctx.Data["PageIsSettingsKeys"] = true + + if ctx.HasError() { + DeployKeys(ctx) + return + } + + key, token, err := asymkey_model.AddHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, !form.IsWritable) + if err != nil { + switch { + case asymkey_model.IsErrHTTPSDeployKeyNameAlreadyUsed(err): + ctx.Flash.Error(ctx.Tr("repo.settings.key_name_used")) + default: + ctx.ServerError("AddHTTPSDeployKey", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") + return + } + + log.Trace("HTTPS deploy key added: %d", key.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.https_deploy_key_created", key.Name, token)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") +} + +// DeleteHTTPSDeployKey deletes a single HTTPS deploy key scoped to the +// current repository. +func DeleteHTTPSDeployKey(ctx *context.Context) { + if err := asymkey_model.DeleteHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteHTTPSDeployKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") +} diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index 154d01fda4..52c4087ca0 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -5,9 +5,11 @@ package setting import ( "net/http" + "strconv" "testing" asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" @@ -431,3 +433,43 @@ func TestHandleSettingsPostMirrorPreservesExistingUsername(t *testing.T) { require.True(t, ok) assert.Equal(t, "updated-password", password) } + +func TestHTTPSDeployKeyCreateAndDelete(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys/https") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + + web.SetForm(ctx, &forms.HTTPSDeployKeyForm{ + Title: "ci-writable", + IsWritable: true, + }) + HTTPSDeployKeysPost(ctx) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) + + keys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + require.Len(t, keys, 1) + assert.Equal(t, "ci-writable", keys[0].Name) + assert.False(t, keys[0].IsReadOnly()) + + // The plaintext token must be stashed on the flash for one-time display. + flash := ctx.Flash + require.NotNil(t, flash) + assert.Contains(t, flash.SuccessMsg, "ci-writable") + + // Now delete it. + delCtx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys/https/delete") + contexttest.LoadUser(t, delCtx, 2) + contexttest.LoadRepo(t, delCtx, 1) + delCtx.Req.Form.Set("id", strconv.FormatInt(keys[0].ID, 10)) + DeleteHTTPSDeployKey(delCtx) + assert.NotEqual(t, http.StatusInternalServerError, delCtx.Resp.WrittenStatus()) + + keys, err = db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + assert.Empty(t, keys) +} diff --git a/routers/web/web.go b/routers/web/web.go index ecd75250d2..9ec49d179c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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,17 @@ func newWebAuthMiddleware() *AuthMiddleware { webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true) webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true) + // AllowDeployToken narrows HTTPS deploy-token authentication to the + // request contexts that need it (git smart-HTTP). Without this gate, a + // deploy token would authenticate as the repo owner on every Basic-auth + // endpoint — REST API, attachments, feeds — and act as a full-owner PAT. + 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,6 +135,12 @@ func newWebAuthMiddleware() *AuthMiddleware { if allowOAuth2 { group.Add(&auth_service.OAuth2{}) } + if allowDeployToken { + // Must come before Basic so a valid deploy token short-circuits + // the fall-through into username/password sign-in (which would + // reject the 40-hex token and leave the caller with a 401). + group.Add(&auth_service.HTTPSDeployToken{}) + } if allowBasic { group.Add(&auth_service.Basic{}) } @@ -1208,6 +1222,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Combo("").Get(repo_setting.DeployKeys). Post(web.Bind(forms.AddKeyForm{}), repo_setting.DeployKeysPost) m.Post("/delete", repo_setting.DeleteDeployKey) + m.Post("/https", web.Bind(forms.HTTPSDeployKeyForm{}), repo_setting.HTTPSDeployKeysPost) + m.Post("/https/delete", repo_setting.DeleteHTTPSDeployKey) }) m.Group("/lfs", func() { @@ -1738,7 +1754,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // 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) diff --git a/services/auth/basic.go b/services/auth/basic.go index e8a4a2e8f7..4842301865 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -24,10 +24,11 @@ var ( // BasicMethodName is the constant name of the basic authentication method const ( - BasicMethodName = "basic" - AccessTokenMethodName = "access_token" - OAuth2TokenMethodName = "oauth2_token" - ActionTokenMethodName = "action_token" + BasicMethodName = "basic" + AccessTokenMethodName = "access_token" + OAuth2TokenMethodName = "oauth2_token" + ActionTokenMethodName = "action_token" + HTTPSDeployTokenMethodName = "https_deploy_token" ) // Basic implements the Auth interface and authenticates requests (API requests diff --git a/services/auth/https_deploy_token.go b/services/auth/https_deploy_token.go new file mode 100644 index 0000000000..9b81bfcb63 --- /dev/null +++ b/services/auth/https_deploy_token.go @@ -0,0 +1,85 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/httpauth" + "code.gitea.io/gitea/modules/log" +) + +// Ensure the struct implements the interface. +var _ Method = &HTTPSDeployToken{} + +// HTTPSDeployToken authenticates HTTP Basic-auth credentials whose token half +// matches a row in the https_deploy_key table. It is deliberately *not* +// registered globally: callers add it to an auth group only for request +// contexts where a repo-scoped bearer token makes sense (currently the git +// smart-HTTP router). See routers/web/web.go for the gating flag. +type HTTPSDeployToken struct{} + +// Name returns the name of this auth method. +func (h *HTTPSDeployToken) Name() string { + return HTTPSDeployTokenMethodName +} + +// Verify parses the Basic-auth header, resolves the token to an +// HTTPSDeployKey, and returns the bound repository owner. The deploy-key +// metadata is stashed on the data store so downstream permission logic can +// constrain the request to the bound repo and access mode. +func (h *HTTPSDeployToken) Verify(req *http.Request, _ http.ResponseWriter, store DataStore, _ SessionStore) (*user_model.User, error) { + authToken := extractBasicAuthToken(req) + if authToken == "" { + return nil, nil //nolint:nilnil // the auth method is not applicable + } + + key, err := asymkey_model.VerifyHTTPSDeployToken(req.Context(), authToken) + if err != nil { + if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) { + return nil, nil //nolint:nilnil // not our token — fall through to regular basic auth + } + return nil, err + } + + repo, err := repo_model.GetRepositoryByID(req.Context(), key.RepoID) + if err != nil { + log.Error("HTTPSDeployToken: GetRepositoryByID(%d): %v", key.RepoID, err) + return nil, err + } + if err := repo.LoadOwner(req.Context()); err != nil { + log.Error("HTTPSDeployToken: LoadOwner for repo %d: %v", repo.ID, err) + return nil, err + } + + log.Trace("HTTPSDeployToken: valid HTTPS deploy key for repo[%d]", repo.ID) + store.GetData()["LoginMethod"] = HTTPSDeployTokenMethodName + store.GetData()["IsDeployToken"] = true + store.GetData()["DeployTokenID"] = key.ID + store.GetData()["DeployTokenRepoID"] = key.RepoID + store.GetData()["DeployTokenMode"] = key.Mode + return repo.Owner, nil +} + +// extractBasicAuthToken pulls the credential string out of an HTTP Basic +// Authorization header. It returns the password half when present (the +// conventional token-in-password pattern used by git credential helpers) and +// otherwise the username half. Returns "" if no Basic header is present. +func extractBasicAuthToken(req *http.Request) string { + authHeader := req.Header.Get("Authorization") + if authHeader == "" { + return "" + } + parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) + if !ok || parsed.BasicAuth == nil { + return "" + } + if parsed.BasicAuth.Password != "" && parsed.BasicAuth.Password != "x-oauth-basic" { + return parsed.BasicAuth.Password + } + return parsed.BasicAuth.Username +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d8e019f860..c2a0bab95f 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -734,3 +734,26 @@ func (f *AddTimeManuallyForm) Validate(req *http.Request, errs binding.Errors) b type SaveTopicForm struct { Topics []string `binding:"topics;Required;"` } + +// DeadlineForm hold the validation rules for deadlines +type DeadlineForm struct { + DateString string `form:"date" binding:"Required;Size(10)"` +} + +// Validate validates the fields +func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + +// HTTPSDeployKeyForm form for adding an HTTPS deploy key to a repository. +type HTTPSDeployKeyForm struct { + Title string `binding:"Required;MaxSize(50)"` + IsWritable bool +} + +// Validate validates the fields +func (f *HTTPSDeployKeyForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/templates/repo/settings/deploy_keys.tmpl b/templates/repo/settings/deploy_keys.tmpl index c4bffbbf08..39b280f220 100644 --- a/templates/repo/settings/deploy_keys.tmpl +++ b/templates/repo/settings/deploy_keys.tmpl @@ -77,4 +77,71 @@ {{template "base/modal_actions_confirm" .}} +
{{ctx.Locale.Tr "repo.settings.deploy_key_deletion_desc"}}