0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-12 11:13:21 +02:00

feat: add HTTPS deploy keys for repository authentication

Add per-repository HTTPS deploy key support, allowing authentication
for git HTTPS operations without user accounts. This includes:

- New https_deploy_key table and migration (v332)
- HTTPSDeployKey model with token hashing and CRUD operations
- Git HTTP authentication middleware for deploy tokens
- UI for managing HTTPS deploy keys in repository settings
- Scope enforcement (read-only vs writable, wiki unit check)
- Integration tests for clone/push authorization

Fixes #2051

Co-Authored-By: Claude Mythos <noreply@anthropic.com>
This commit is contained in:
Claude Mythos 2026-05-06 00:44:00 +08:00
parent a5fd8e7e86
commit ec7608d355
18 changed files with 896 additions and 5 deletions

View File

@ -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

View File

@ -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{}
}

View File

@ -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)
}

View File

@ -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",

View File

@ -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

View File

@ -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
}

View File

@ -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))
}

View File

@ -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)
}

View File

@ -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",

View File

@ -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

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -77,4 +77,71 @@
{{template "base/modal_actions_confirm" .}}
</div>
<div class="repo-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.https_deploy_keys"}}
<div class="ui right">
<button class="ui primary tiny show-panel toggle button" data-panel="#add-https-deploy-key-panel">{{ctx.Locale.Tr "repo.settings.add_https_deploy_key"}}</button>
</div>
</h4>
<div class="ui attached segment">
<div class="tw-hidden tw-mb-4" id="add-https-deploy-key-panel">
<form class="ui form" action="{{.Link}}/https" method="post">
<div class="field">
{{ctx.Locale.Tr "repo.settings.https_deploy_key_desc"}}
</div>
<div class="field">
<label for="https-deploy-key-title">{{ctx.Locale.Tr "repo.settings.title"}}</label>
<input id="https-deploy-key-title" name="title" required>
</div>
<div class="field">
<div class="ui checkbox">
<input id="https-deploy-key-is-writable" name="is_writable" type="checkbox" value="1">
<label for="https-deploy-key-is-writable">
{{ctx.Locale.Tr "repo.settings.is_writable"}}
</label>
<small class="tw-pl-[26px]">{{ctx.Locale.Tr "repo.settings.is_writable_info"}}</small>
</div>
</div>
<button class="ui primary button">
{{ctx.Locale.Tr "repo.settings.add_https_deploy_key"}}
</button>
<button class="ui hide-panel button" data-panel="#add-https-deploy-key-panel">
{{ctx.Locale.Tr "cancel"}}
</button>
</form>
</div>
{{if .HTTPSDeploykeys}}
<div class="flex-list">
{{range .HTTPSDeploykeys}}
<div class="flex-item">
<div class="flex-item-leading">
<span class="{{if .HasRecentActivity}}tw-text-green{{end}}" {{if .HasRecentActivity}}data-tooltip-content="{{ctx.Locale.Tr "settings.key_state_desc"}}"{{end}}>{{svg "octicon-key" 32}}</span>
</div>
<div class="flex-item-main">
<div class="flex-item-title">{{.Name}}</div>
<div class="flex-item-body">
<i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="tw-text-green"{{end}}>{{DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}} - <span>{{ctx.Locale.Tr "settings.can_read_info"}}{{if not .IsReadOnly}} / {{ctx.Locale.Tr "settings.can_write_info"}} {{end}}</span></i>
</div>
</div>
<div class="flex-item-trailing">
<button class="ui red tiny button link-action" data-modal-confirm="#repo-https-deploy-key-delete-modal" data-url="{{$.Link}}/https/delete?id={{.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}}
</button>
</div>
</div>
{{end}}
</div>
{{else}}
{{ctx.Locale.Tr "repo.settings.no_https_deploy_keys"}}
{{end}}
</div>
</div>
<div class="ui small modal" id="repo-https-deploy-key-delete-modal">
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.settings.deploy_key_deletion"}}</div>
<div class="content"><p>{{ctx.Locale.Tr "repo.settings.deploy_key_deletion_desc"}}</p></div>
{{template "base/modal_actions_confirm" .}}
</div>
{{template "repo/settings/layout_footer" .}}

View File

@ -0,0 +1,135 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
asymkey_model "code.gitea.io/gitea/models/asymkey"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// addTestHTTPSDeployKey is a small helper that creates an HTTPS deploy key in
// the test database and returns the plaintext token. It assumes the fixtures
// already hold a repo with the given ID.
func addTestHTTPSDeployKey(t *testing.T, repoID int64, name string, readOnly bool) string {
t.Helper()
_, token, err := asymkey_model.AddHTTPSDeployKey(t.Context(), repoID, name, readOnly)
require.NoError(t, err)
return token
}
func TestHTTPSDeployKeyClone_Read(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
token := addTestHTTPSDeployKey(t, repo.ID, "https-clone-read", true)
req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-upload-pack")
req.Request.SetBasicAuth("x-deploy-token", token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "service=git-upload-pack")
}
func TestHTTPSDeployKeyPush_ReadOnlyDenied(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
token := addTestHTTPSDeployKey(t, repo.ID, "https-push-denied", true)
req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-receive-pack")
req.Request.SetBasicAuth("x-deploy-token", token)
MakeRequest(t, req, http.StatusForbidden)
}
func TestHTTPSDeployKeyPush_WriteAllowed(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
token := addTestHTTPSDeployKey(t, repo.ID, "https-push-allowed", false)
// `GET info/refs?service=git-receive-pack` is the same auth path a
// real push would hit first; a 200 means the server would accept the
// push as far as auth is concerned.
req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-receive-pack")
req.Request.SetBasicAuth("x-deploy-token", token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "service=git-receive-pack")
}
func TestHTTPSDeployKeyCrossRepo(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
token := addTestHTTPSDeployKey(t, repo1.ID, "https-cross-repo", false)
req := NewRequest(t, "GET", "/"+repo2.FullName()+"/info/refs?service=git-upload-pack")
req.Request.SetBasicAuth("x-deploy-token", token)
// A cross-repo token should be treated exactly like a missing credential
// for repo2 — the server must not leak whether the token is otherwise
// valid.
MakeRequest(t, req, http.StatusNotFound)
}
func TestHTTPSDeployKeyInvalidToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// 40 hex chars that do not correspond to any stored token: the server
// must fall through to Basic auth which then rejects it.
req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-upload-pack")
req.Request.SetBasicAuth("x-deploy-token", "0123456789abcdef0123456789abcdef01234567")
MakeRequest(t, req, http.StatusUnauthorized)
}
// TestHTTPSDeployKeyScopedToGitHTTP asserts that a deploy token does NOT
// authenticate on non-git Basic-auth endpoints. Historically the token check
// lived inside auth.Basic.VerifyAuthToken, which made the token behave like a
// full owner-scoped PAT on the REST API, attachments, feeds, etc.
func TestHTTPSDeployKeyScopedToGitHTTP(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
token := addTestHTTPSDeployKey(t, repo.ID, "scope-guard", false)
// The token works on a git-HTTP path…
gitReq := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-upload-pack")
gitReq.Request.SetBasicAuth("x-deploy-token", token)
MakeRequest(t, gitReq, http.StatusOK)
// …but the same token on the REST API must NOT authenticate. The API
// layer only knows user/PAT credentials, so the request is rejected as
// an invalid credential (401) rather than silently admitted as the
// repo owner.
apiReq := NewRequest(t, "GET", "/api/v1/repos/"+repo.FullName())
apiReq.Request.SetBasicAuth("x-deploy-token", token)
MakeRequest(t, apiReq, http.StatusUnauthorized)
}
// TestHTTPSDeployKeyRespectsDisabledWikiUnit asserts that a deploy token
// cannot reach the wiki of a repository whose wiki unit has been disabled.
// Before the fix the deploy-token branch in httpBase short-circuited before
// the unit-enablement check that runs for password / PAT paths.
func TestHTTPSDeployKeyRespectsDisabledWikiUnit(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), repo, nil, []unit.Type{unit.TypeWiki}))
token := addTestHTTPSDeployKey(t, repo.ID, "wiki-guard", false)
wikiReq := NewRequest(t, "GET", "/"+repo.FullName()+".wiki/info/refs?service=git-upload-pack")
wikiReq.Request.SetBasicAuth("x-deploy-token", token)
MakeRequest(t, wikiReq, http.StatusForbidden)
}