mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-10 14:01:54 +02:00
Merge 3a5148e450d02059a9ac8fc572a8ea4eb36ff621 into a5d81d9ce230aaa6e1021b6236ca01cb6d2b56c3
This commit is contained in:
commit
3478d59906
@ -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
|
||||
|
||||
199
models/asymkey/https_deploy_key.go
Normal file
199
models/asymkey/https_deploy_key.go
Normal file
@ -0,0 +1,199 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// HTTPSDeployKeyTokenLength is the expected length of a hex-encoded deploy
|
||||
// token (20 random bytes → 40 hex chars).
|
||||
const HTTPSDeployKeyTokenLength = 40
|
||||
|
||||
// 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) != HTTPSDeployKeyTokenLength {
|
||||
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")
|
||||
}
|
||||
|
||||
salt := util.CryptoRandomString(10)
|
||||
tokenBytes := util.CryptoRandomBytes(20)
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
|
||||
mode := perm.AccessModeRead
|
||||
if !readOnly {
|
||||
mode = perm.AccessModeWrite
|
||||
}
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
key := &HTTPSDeployKey{
|
||||
RepoID: repoID,
|
||||
Name: name,
|
||||
TokenHash: auth_model.HashToken(token, salt),
|
||||
TokenSalt: salt,
|
||||
TokenLastEight: token[len(token)-8:],
|
||||
Mode: mode,
|
||||
CreatedUnix: now,
|
||||
UpdatedUnix: now,
|
||||
}
|
||||
|
||||
insertErr := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
has, err := db.GetEngine(ctx).Where("repo_id = ? AND name = ?", repoID, name).Exist(new(HTTPSDeployKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return ErrHTTPSDeployKeyNameAlreadyUsed{RepoID: repoID, Name: name}
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).NoAutoTime().Insert(key)
|
||||
if err != nil {
|
||||
return ErrHTTPSDeployKeyNameAlreadyUsed{RepoID: repoID, Name: name}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if insertErr != nil {
|
||||
return nil, "", insertErr
|
||||
}
|
||||
|
||||
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) {
|
||||
_ = auth_model.HashToken(token, util.CryptoRandomString(10)) // dummy to prevent timing side-channel
|
||||
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{}
|
||||
}
|
||||
416
models/asymkey/https_deploy_key_test.go
Normal file
416
models/asymkey/https_deploy_key_test.go
Normal file
@ -0,0 +1,416 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package asymkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHTTPSDeployKeyTokenLength(t *testing.T) {
|
||||
// Verify the token length constant is used for validation.
|
||||
assert.True(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength)))
|
||||
assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength-1)))
|
||||
assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength+1)))
|
||||
assert.False(t, tokenIsValidFormat(strings.Repeat("g", HTTPSDeployKeyTokenLength)))
|
||||
}
|
||||
|
||||
func TestTokenIsValidFormatEdgeCases(t *testing.T) {
|
||||
// Valid: 40 lowercase hex chars
|
||||
assert.True(t, tokenIsValidFormat("aaaaaaaaaa000000000011111111112222222222"))
|
||||
|
||||
// Reject uppercase hex
|
||||
assert.False(t, tokenIsValidFormat(strings.Repeat("A", HTTPSDeployKeyTokenLength)))
|
||||
assert.False(t, tokenIsValidFormat("AAAAAAAAAA000000000011111111112222222222"))
|
||||
|
||||
// Reject whitespace
|
||||
assert.False(t, tokenIsValidFormat(" "+strings.Repeat("a", HTTPSDeployKeyTokenLength-1)))
|
||||
assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength-1)+" "))
|
||||
assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength/2)+" "+strings.Repeat("a", HTTPSDeployKeyTokenLength/2-1)))
|
||||
|
||||
// Reject empty string
|
||||
assert.False(t, tokenIsValidFormat(""))
|
||||
|
||||
// Reject special characters
|
||||
assert.False(t, tokenIsValidFormat(strings.Repeat("!", HTTPSDeployKeyTokenLength)))
|
||||
assert.False(t, tokenIsValidFormat(strings.Repeat("@", HTTPSDeployKeyTokenLength)))
|
||||
|
||||
// Reject mixed case
|
||||
assert.False(t, tokenIsValidFormat("AaAaAaAaAa0000000000111111111122222222"))
|
||||
}
|
||||
|
||||
func TestAddHTTPSDeployKeyEmptyName(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
_, _, err := AddHTTPSDeployKey(t.Context(), 1, "", true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "empty")
|
||||
}
|
||||
|
||||
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, HTTPSDeployKeyTokenLength, "token should match HTTPSDeployKeyTokenLength")
|
||||
for _, r := range token {
|
||||
ok := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')
|
||||
assert.Truef(t, ok, "token contains non-hex char %q", r)
|
||||
}
|
||||
|
||||
// Verify TokenHash is non-empty (token was hashed)
|
||||
assert.NotEmpty(t, key.TokenHash, "TokenHash must be set after create")
|
||||
|
||||
// Verify TokenLastEight matches the actual token suffix
|
||||
assert.Equal(t, token[len(token)-8:], key.TokenLastEight, "TokenLastEight must match token suffix")
|
||||
|
||||
// Verify timestamps are set
|
||||
assert.NotZero(t, key.CreatedUnix, "CreatedUnix must be set after create")
|
||||
assert.NotZero(t, key.UpdatedUnix, "UpdatedUnix must be set after create")
|
||||
|
||||
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")
|
||||
|
||||
_ = DeleteHTTPSDeployKey(t.Context(), 1, key.ID)
|
||||
}
|
||||
|
||||
func TestAddHTTPSDeployKey_NameUnique(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
keyA, _, err := AddHTTPSDeployKey(t.Context(), 1, "dup", false)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, keyA.ID)
|
||||
|
||||
_, _, 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.
|
||||
keyB, _, err := AddHTTPSDeployKey(t.Context(), 2, "dup", false)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 2, keyB.ID)
|
||||
}
|
||||
|
||||
func TestListHTTPSDeployKeys(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
keyA, _, err := AddHTTPSDeployKey(t.Context(), 1, "t-list-a", true)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, keyA.ID)
|
||||
|
||||
keyB, _, err := AddHTTPSDeployKey(t.Context(), 1, "t-list-b", false)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, keyB.ID)
|
||||
|
||||
keyC, _, err := AddHTTPSDeployKey(t.Context(), 2, "t-list-c", true)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 2, keyC.ID)
|
||||
|
||||
keys, err := db.Find[HTTPSDeployKey](t.Context(),
|
||||
ListHTTPSDeployKeysOptions{RepoID: 1})
|
||||
require.NoError(t, err)
|
||||
names := make(map[string]struct{}, len(keys))
|
||||
for _, k := range keys {
|
||||
names[k.Name] = struct{}{}
|
||||
}
|
||||
assert.Contains(t, names, "t-list-a")
|
||||
assert.Contains(t, names, "t-list-b")
|
||||
|
||||
keys, err = db.Find[HTTPSDeployKey](t.Context(),
|
||||
ListHTTPSDeployKeysOptions{RepoID: 2})
|
||||
require.NoError(t, err)
|
||||
names = make(map[string]struct{}, len(keys))
|
||||
for _, k := range keys {
|
||||
names[k.Name] = struct{}{}
|
||||
}
|
||||
assert.Contains(t, names, "t-list-c")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
_ = DeleteHTTPSDeployKey(t.Context(), 1, key.ID)
|
||||
}
|
||||
|
||||
func TestHTTPSDeployKeyAfterLoad(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
key, token, err := AddHTTPSDeployKey(t.Context(), 1, "afterload-test", true)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID)
|
||||
|
||||
// Fresh key: CreatedUnix == UpdatedUnix, so HasUsed should be false
|
||||
got, err := GetHTTPSDeployKeyByID(t.Context(), key.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, got.HasUsed, "fresh key should not have HasUsed")
|
||||
assert.True(t, got.HasRecentActivity, "fresh key should have recent activity")
|
||||
|
||||
// After verify: UpdatedUnix > CreatedUnix, so HasUsed should be true
|
||||
// Sleep to ensure second-level granularity differs.
|
||||
time.Sleep(1 * time.Second)
|
||||
_, err = VerifyHTTPSDeployToken(t.Context(), token)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err = GetHTTPSDeployKeyByID(t.Context(), key.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.HasUsed, "key should show HasUsed after verify")
|
||||
assert.True(t, got.HasRecentActivity, "key should still have recent activity")
|
||||
assert.NotEqual(t, got.CreatedUnix, got.UpdatedUnix, "UpdatedUnix should differ from CreatedUnix after use")
|
||||
}
|
||||
|
||||
func TestVerifyHTTPSDeployTokenUpdatesTimestamp(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
key, token, err := AddHTTPSDeployKey(t.Context(), 1, "timestamp-test", false)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID)
|
||||
|
||||
originalUpdated := key.UpdatedUnix
|
||||
|
||||
// Sleep to ensure second-level granularity differs.
|
||||
time.Sleep(1 * time.Second)
|
||||
got, err := VerifyHTTPSDeployToken(t.Context(), token)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, got.UpdatedUnix, originalUpdated, "UpdatedUnix should increase after verification")
|
||||
}
|
||||
|
||||
func TestAddHTTPSDeployKey_ConcurrentInsert(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// Attempt concurrent inserts with the same (repoID, name) inside a transaction.
|
||||
// Only one should succeed; the other must receive ErrHTTPSDeployKeyNameAlreadyUsed.
|
||||
var results [3]struct {
|
||||
key *HTTPSDeployKey
|
||||
err error
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
for idx := range 3 {
|
||||
go func(i int) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key, _, err := AddHTTPSDeployKey(ctx, 1, "concurrent-key", true)
|
||||
results[i] = struct {
|
||||
key *HTTPSDeployKey
|
||||
err error
|
||||
}{key: key, err: err}
|
||||
}(idx)
|
||||
}
|
||||
|
||||
for range 3 {
|
||||
<-done
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for i := range results {
|
||||
if results[i].err == nil {
|
||||
successCount++
|
||||
assert.NotNil(t, results[i].key, "result[%d]: key should not be nil on success", i)
|
||||
_ = DeleteHTTPSDeployKey(t.Context(), 1, results[i].key.ID)
|
||||
} else {
|
||||
assert.True(t, IsErrHTTPSDeployKeyNameAlreadyUsed(results[i].err),
|
||||
"result[%d]: expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v",
|
||||
i, results[i].err, results[i].err)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, successCount, "exactly one concurrent insert should succeed")
|
||||
}
|
||||
|
||||
func TestVerifyHTTPSDeployToken_TimingResistance(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// Create a valid key so that valid-format tokens trigger the hash lookup path.
|
||||
key, _, err := AddHTTPSDeployKey(t.Context(), 1, "timing-test", true)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID)
|
||||
|
||||
// Generate a fake valid-format token that does not match any key.
|
||||
validFormatToken := func() string {
|
||||
b := make([]byte, 20)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Measure the time to verify a valid-format but wrong token.
|
||||
validFormat := validFormatToken()
|
||||
start := time.Now()
|
||||
for range 5 {
|
||||
_, _ = VerifyHTTPSDeployToken(t.Context(), validFormat)
|
||||
}
|
||||
validFormatDuration := time.Since(start)
|
||||
|
||||
// Measure the time to verify an invalid-format token.
|
||||
// If the implementation short-circuits without a dummy hash,
|
||||
// this will be orders of magnitude faster than the valid-format case.
|
||||
invalidFormat := "short"
|
||||
start = time.Now()
|
||||
for range 5 {
|
||||
_, _ = VerifyHTTPSDeployToken(t.Context(), invalidFormat)
|
||||
}
|
||||
invalidFormatDuration := time.Since(start)
|
||||
|
||||
// The invalid-format path should not be dramatically faster.
|
||||
// Allow a generous margin (invalid should be within 10x of valid)
|
||||
// to account for CI noise while still catching the microsecond-vs-millisecond gap.
|
||||
if invalidFormatDuration < validFormatDuration/10 {
|
||||
t.Errorf("invalid-format verification (%v) is too fast compared to valid-format (%v); "+
|
||||
"possible timing oracle", invalidFormatDuration, validFormatDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHTTPSDeployToken_DummyHash(t *testing.T) {
|
||||
// Verify that the dummy hash in the invalid-format path actually performs
|
||||
// a pbkdf2 computation by confirming the HashToken call is reachable.
|
||||
// This is a structural check: the dummy hash uses a random salt,
|
||||
// so the result should be deterministic for the same inputs.
|
||||
salt := "test-salt-12345"
|
||||
hash := auth_model.HashToken("dummy-token", salt)
|
||||
assert.NotEmpty(t, hash, "HashToken should produce non-empty output")
|
||||
assert.NotEqual(t, "dummy-token", hash, "HashToken should not return the input")
|
||||
}
|
||||
|
||||
func TestAddHTTPSDeployKey_WithinTransaction(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// Create a key outside a transaction first.
|
||||
key1, _, err := AddHTTPSDeployKey(t.Context(), 1, "in-tx-key", true)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, key1.ID)
|
||||
|
||||
// Attempt to create a duplicate inside a transaction.
|
||||
// The operation should detect the conflict and return the proper error.
|
||||
var insertErr error
|
||||
_ = db.WithTx(t.Context(), func(ctx context.Context) error {
|
||||
_, _, insertErr = AddHTTPSDeployKey(ctx, 1, "in-tx-key", true)
|
||||
return insertErr
|
||||
})
|
||||
require.Error(t, insertErr)
|
||||
assert.True(t, IsErrHTTPSDeployKeyNameAlreadyUsed(insertErr),
|
||||
"expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", insertErr, insertErr)
|
||||
}
|
||||
|
||||
func TestHTTPSDeployKeyModeSelection(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
readOnlyKey, _, err := AddHTTPSDeployKey(t.Context(), 1, "mode-read", true)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, readOnlyKey.ID)
|
||||
assert.True(t, readOnlyKey.IsReadOnly())
|
||||
|
||||
writeKey, _, err := AddHTTPSDeployKey(t.Context(), 1, "mode-write", false)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, writeKey.ID)
|
||||
assert.False(t, writeKey.IsReadOnly())
|
||||
}
|
||||
|
||||
func TestHTTPSDeployKeyTokenGeneration(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// Verify that each key gets a unique token.
|
||||
_, token1, err := AddHTTPSDeployKey(t.Context(), 1, "token-gen-1", true)
|
||||
require.NoError(t, err)
|
||||
_, token2, err := AddHTTPSDeployKey(t.Context(), 1, "token-gen-2", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, token1, token2, "each key should have a unique token")
|
||||
|
||||
// Verify that generated tokens are always valid format.
|
||||
assert.True(t, tokenIsValidFormat(token1))
|
||||
assert.True(t, tokenIsValidFormat(token2))
|
||||
}
|
||||
|
||||
func TestVerifyHTTPSDeployToken_LastEightIndex(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
key, token, err := AddHTTPSDeployKey(t.Context(), 1, "last-eight", true)
|
||||
require.NoError(t, err)
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID)
|
||||
|
||||
// Verify that the last eight characters of the token match the index column.
|
||||
assert.Equal(t, token[len(token)-8:], key.TokenLastEight)
|
||||
|
||||
// Verify that the token lookup works even when other keys share the same suffix.
|
||||
// Create a dummy key with a crafted token that shares the last eight chars.
|
||||
salt := util.CryptoRandomString(10)
|
||||
dummyToken := strings.Repeat("a", HTTPSDeployKeyTokenLength-8) + key.TokenLastEight
|
||||
dummyHash := auth_model.HashToken(dummyToken, salt)
|
||||
|
||||
dummyKey := &HTTPSDeployKey{
|
||||
RepoID: 1,
|
||||
Name: "last-eight-collide",
|
||||
TokenHash: dummyHash,
|
||||
TokenSalt: salt,
|
||||
TokenLastEight: key.TokenLastEight,
|
||||
Mode: 1,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), dummyKey))
|
||||
defer DeleteHTTPSDeployKey(t.Context(), 1, dummyKey.ID)
|
||||
|
||||
// Verify the original token still resolves to the correct key.
|
||||
got, err := VerifyHTTPSDeployToken(t.Context(), token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, key.ID, got.ID)
|
||||
}
|
||||
@ -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",
|
||||
|
||||
3
models/fixtures/https_deploy_key.yml
Normal file
3
models/fixtures/https_deploy_key.yml
Normal 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
|
||||
@ -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
|
||||
}
|
||||
|
||||
26
models/migrations/v1_27/v332.go
Normal file
26
models/migrations/v1_27/v332.go
Normal 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))
|
||||
}
|
||||
52
models/migrations/v1_27/v332_test.go
Normal file
52
models/migrations/v1_27/v332_test.go
Normal 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)
|
||||
}
|
||||
@ -47,3 +47,45 @@ type CreateKeyOption struct {
|
||||
// required: false
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
// HTTPSDeployKey an HTTPS deploy key/token
|
||||
type HTTPSDeployKey struct {
|
||||
// ID is the unique identifier for the deploy key
|
||||
ID int64 `json:"id"`
|
||||
// Name is the human-readable name for the key
|
||||
Name string `json:"name"`
|
||||
// URL is the API URL for this deploy key
|
||||
URL string `json:"url"`
|
||||
// Token is the plaintext token, only returned on creation
|
||||
Token string `json:"token,omitempty"`
|
||||
// TokenLastEight is the last 8 characters of the token for identification
|
||||
TokenLastEight string `json:"token_last_eight"`
|
||||
// ReadOnly indicates if the key has read-only access
|
||||
ReadOnly bool `json:"read_only"`
|
||||
// HasUsed indicates if the key has been used for authentication
|
||||
HasUsed bool `json:"has_used"`
|
||||
// HasRecentActivity indicates if the key was used in the last 7 days
|
||||
HasRecentActivity bool `json:"has_recent_activity"`
|
||||
// Repository is the repository this deploy key belongs to
|
||||
Repository *Repository `json:"repository,omitempty"`
|
||||
// swagger:strfmt date-time
|
||||
// Created is the time when the deploy key was created
|
||||
Created time.Time `json:"created_at"`
|
||||
// swagger:strfmt date-time
|
||||
// Updated is the time when the deploy key was last updated
|
||||
Updated time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateHTTPSDeployKeyOption options when creating an HTTPS deploy key
|
||||
// swagger:model CreateHTTPSDeployKeyOption
|
||||
type CreateHTTPSDeployKeyOption struct {
|
||||
// Name of the key to add
|
||||
//
|
||||
// required: true
|
||||
// unique: true
|
||||
Name string `json:"name" binding:"Required;MaxSize(50)"`
|
||||
// Describe if the key has only read access or read/write
|
||||
//
|
||||
// required: false
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
@ -2379,6 +2379,12 @@
|
||||
"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.https_deploy_key_created_info": "Your HTTPS deploy key \"%s\" was created successfully. Copy the token below — it will not be shown again:",
|
||||
"repo.settings.branches": "Branches",
|
||||
"repo.settings.protected_branch": "Branch Protection",
|
||||
"repo.settings.protected_branch.save_rule": "Save Rule",
|
||||
|
||||
@ -1279,6 +1279,12 @@ func Routes() *web.Router {
|
||||
m.Combo("/{id}").Get(repo.GetDeployKey).
|
||||
Delete(repo.DeleteDeploykey)
|
||||
}, reqToken(), reqAdmin())
|
||||
m.Group("/https_keys", func() {
|
||||
m.Combo("").Get(repo.ListHTTPSDeployKeys).
|
||||
Post(bind(api.CreateHTTPSDeployKeyOption{}), repo.CreateHTTPSDeployKey)
|
||||
m.Combo("/{id}").Get(repo.GetHTTPSDeployKey).
|
||||
Delete(repo.DeleteHTTPSDeployKey)
|
||||
}, reqToken(), reqAdmin())
|
||||
m.Group("/times", func() {
|
||||
m.Combo("").Get(repo.ListTrackedTimesByRepository)
|
||||
m.Combo("/{timetrackingusername}").Get(repo.ListTrackedTimesByUser)
|
||||
|
||||
242
routers/api/v1/repo/https_key.go
Normal file
242
routers/api/v1/repo/https_key.go
Normal file
@ -0,0 +1,242 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
asymkey_model "code.gitea.io/gitea/models/asymkey"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
func composeHTTPSDeployKeysAPILink(owner, name string) string {
|
||||
return setting.AppURL + "api/v1/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/https_keys/"
|
||||
}
|
||||
|
||||
// ListHTTPSDeployKeys list all the HTTPS deploy keys of a repository
|
||||
func ListHTTPSDeployKeys(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/https_keys repository repoListHTTPSKeys
|
||||
// ---
|
||||
// summary: List a repository's HTTPS deploy keys
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/HTTPSDeployKeyList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
opts := asymkey_model.ListHTTPSDeployKeysOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
}
|
||||
|
||||
keys, count, err := db.FindAndCount[asymkey_model.HTTPSDeployKey](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiLink := composeHTTPSDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)
|
||||
apiKeys := make([]*api.HTTPSDeployKey, len(keys))
|
||||
for i := range keys {
|
||||
apiKeys[i] = convert.ToHTTPSDeployKey(apiLink, keys[i])
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, &apiKeys)
|
||||
}
|
||||
|
||||
// GetHTTPSDeployKey get an HTTPS deploy key by id
|
||||
func GetHTTPSDeployKey(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/https_keys/{id} repository repoGetHTTPSKey
|
||||
// ---
|
||||
// summary: Get a repository's HTTPS deploy key by id
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the key to get
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/HTTPSDeployKey"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
key, err := asymkey_model.GetHTTPSDeployKeyByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if key.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
apiLink := composeHTTPSDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)
|
||||
ctx.JSON(http.StatusOK, convert.ToHTTPSDeployKey(apiLink, key))
|
||||
}
|
||||
|
||||
// CreateHTTPSDeployKey create HTTPS deploy key for a repository
|
||||
func CreateHTTPSDeployKey(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/https_keys repository repoCreateHTTPSKey
|
||||
// ---
|
||||
// summary: Add an HTTPS deploy key to a repository
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateHTTPSDeployKeyOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/HTTPSDeployKey"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateHTTPSDeployKeyOption)
|
||||
key, token, err := asymkey_model.AddHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, form.Name, form.ReadOnly)
|
||||
if err != nil {
|
||||
switch {
|
||||
case asymkey_model.IsErrHTTPSDeployKeyNameAlreadyUsed(err):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "A deploy key with the same name already exists")
|
||||
case errors.Is(err, util.ErrInvalidArgument):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("HTTPS deploy key added (API): operator=%s repo=%s key=%s (id=%d)",
|
||||
ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID)
|
||||
|
||||
apiLink := composeHTTPSDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)
|
||||
apiKey := convert.ToHTTPSDeployKey(apiLink, key)
|
||||
apiKey.Token = token
|
||||
ctx.JSON(http.StatusCreated, apiKey)
|
||||
}
|
||||
|
||||
// DeleteHTTPSDeployKey delete HTTPS deploy key for a repository
|
||||
func DeleteHTTPSDeployKey(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/https_keys/{id} repository repoDeleteHTTPSKey
|
||||
// ---
|
||||
// summary: Delete an HTTPS deploy key from a repository
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the key to delete
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
key, err := asymkey_model.GetHTTPSDeployKeyByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if key.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if err := asymkey_model.DeleteHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, key.ID); err != nil {
|
||||
if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("HTTPS deploy key deleted (API): operator=%s repo=%s key=%s (id=%d)",
|
||||
ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID)
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@ -48,3 +48,17 @@ type swaggerResponseDeployKeyList struct {
|
||||
// in:body
|
||||
Body []api.DeployKey `json:"body"`
|
||||
}
|
||||
|
||||
// HTTPSDeployKey
|
||||
// swagger:response HTTPSDeployKey
|
||||
type swaggerResponseHTTPSDeployKey struct {
|
||||
// in:body
|
||||
Body api.HTTPSDeployKey `json:"body"`
|
||||
}
|
||||
|
||||
// HTTPSDeployKeyList
|
||||
// swagger:response HTTPSDeployKeyList
|
||||
type swaggerResponseHTTPSDeployKeyList struct {
|
||||
// in:body
|
||||
Body []api.HTTPSDeployKey `json:"body"`
|
||||
}
|
||||
|
||||
@ -50,6 +50,9 @@ type swaggerParameterBodies struct {
|
||||
// in:body
|
||||
CreateKeyOption api.CreateKeyOption
|
||||
|
||||
// in:body
|
||||
CreateHTTPSDeployKeyOption api.CreateHTTPSDeployKeyOption
|
||||
|
||||
// in:body
|
||||
RenameUserOption api.RenameUserOption
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,19 +4,21 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
asymkey_model "code.gitea.io/gitea/models/asymkey"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
)
|
||||
|
||||
// DeployKeys render the deploy keys list of a repository page
|
||||
// DeployKeys render the deploy keys and HTTPS deploy tokens 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["PageIsSettingsKeys"] = true
|
||||
@ -29,6 +31,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)
|
||||
}
|
||||
|
||||
@ -92,16 +102,81 @@ func DeployKeysPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Deploy key added: %d", ctx.Repo.Repository.ID)
|
||||
log.Trace("Deploy key added: operator=%s repo=%s key=%s (id=%d)",
|
||||
ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID)
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name))
|
||||
ctx.Redirect(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 {
|
||||
id := ctx.FormInt64("id")
|
||||
if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, id); err != nil {
|
||||
ctx.Flash.Error("DeleteDeployKey: " + err.Error())
|
||||
} else {
|
||||
log.Trace("Deploy key deleted: operator=%s repo=%s key-id=%d",
|
||||
ctx.Doer.Name, ctx.Repo.Repository.FullName(), id)
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success"))
|
||||
}
|
||||
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
// HTTPSDeployKeysPost handles creation of an HTTPS deploy key for the current
|
||||
// repository. The plaintext token is rendered inline via ctx.Data so it never
|
||||
// touches cookie-backed flash storage.
|
||||
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() {
|
||||
ctx.Data["HasError"] = true
|
||||
ctx.Data["httpsKeyTitle"] = form.Title
|
||||
DeployKeys(ctx)
|
||||
ctx.HTML(http.StatusOK, tplDeployKeys)
|
||||
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.Data["HasError"] = true
|
||||
ctx.Data["Err_Title"] = true
|
||||
case errors.Is(err, util.ErrInvalidArgument):
|
||||
ctx.Data["HasError"] = true
|
||||
ctx.Data["Err_Title"] = true
|
||||
default:
|
||||
ctx.ServerError("AddHTTPSDeployKey", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["httpsKeyTitle"] = form.Title
|
||||
DeployKeys(ctx)
|
||||
ctx.HTML(http.StatusOK, tplDeployKeys)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("HTTPS deploy key added: operator=%s repo=%s key=%s (id=%d)",
|
||||
ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID)
|
||||
|
||||
// Render the page inline with the token in ctx.Data.
|
||||
// This avoids storing the secret credential in cookie-backed flash.
|
||||
DeployKeys(ctx)
|
||||
ctx.Data["HTTPSDeployKeyToken"] = token
|
||||
ctx.Data["HTTPSDeployKeyName"] = key.Name
|
||||
ctx.HTML(http.StatusOK, tplDeployKeys)
|
||||
}
|
||||
|
||||
// DeleteHTTPSDeployKey deletes a single HTTPS deploy key scoped to the
|
||||
// current repository.
|
||||
func DeleteHTTPSDeployKey(ctx *context.Context) {
|
||||
id := ctx.FormInt64("id")
|
||||
if err := asymkey_model.DeleteHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, id); err != nil {
|
||||
ctx.Flash.Error("DeleteHTTPSDeployKey: " + err.Error())
|
||||
} else {
|
||||
log.Trace("HTTPS deploy key deleted: operator=%s repo=%s key-id=%d",
|
||||
ctx.Doer.Name, ctx.Repo.Repository.FullName(), id)
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success"))
|
||||
}
|
||||
|
||||
|
||||
@ -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,74 @@ 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)
|
||||
|
||||
// Handler must render the template directly (200), not redirect (303).
|
||||
// This avoids putting the plaintext token in a cookie-backed flash.
|
||||
assert.Equal(t, http.StatusOK, 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 on ctx.Data, NOT in flash/cookies.
|
||||
assert.NotEmpty(t, ctx.Data["HTTPSDeployKeyToken"], "token must be in ctx.Data")
|
||||
assert.NotContains(t, ctx.Flash.SuccessMsg, ctx.Data["HTTPSDeployKeyToken"],
|
||||
"token must NOT be in flash — it is a secret credential")
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func TestHTTPSDeployKeysPostValidationError(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)
|
||||
|
||||
// Empty title should fail the Required binding validation.
|
||||
web.SetForm(ctx, &forms.HTTPSDeployKeyForm{
|
||||
Title: "",
|
||||
IsWritable: false,
|
||||
})
|
||||
HTTPSDeployKeysPost(ctx)
|
||||
|
||||
// Must render template inline (200), not redirect — so error state is preserved.
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
|
||||
// Error state must be set so the template can show the panel with errors.
|
||||
hasError, ok := ctx.Data["HasError"].(bool)
|
||||
assert.True(t, ok && hasError, "HasError must be set on validation failure")
|
||||
|
||||
// No HTTPS deploy key should have been created.
|
||||
keys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx,
|
||||
asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, keys)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
85
services/auth/https_deploy_token.go
Normal file
85
services/auth/https_deploy_token.go
Normal 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 deploy 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
|
||||
}
|
||||
@ -698,6 +698,21 @@ func ToDeployKey(apiLink string, key *asymkey_model.DeployKey) *api.DeployKey {
|
||||
}
|
||||
}
|
||||
|
||||
// ToHTTPSDeployKey convert asymkey_model.HTTPSDeployKey to api.HTTPSDeployKey
|
||||
func ToHTTPSDeployKey(apiLink string, key *asymkey_model.HTTPSDeployKey) *api.HTTPSDeployKey {
|
||||
return &api.HTTPSDeployKey{
|
||||
ID: key.ID,
|
||||
Name: key.Name,
|
||||
URL: fmt.Sprintf("%s%d", apiLink, key.ID),
|
||||
TokenLastEight: key.TokenLastEight,
|
||||
ReadOnly: key.Mode == perm.AccessModeRead,
|
||||
HasUsed: key.HasUsed,
|
||||
HasRecentActivity: key.HasRecentActivity,
|
||||
Created: key.CreatedUnix.AsTime(),
|
||||
Updated: key.UpdatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
// ToOrganization convert user_model.User to api.Organization
|
||||
func ToOrganization(ctx context.Context, org *organization.Organization) *api.Organization {
|
||||
return &api.Organization{
|
||||
|
||||
@ -734,3 +734,15 @@ func (f *AddTimeManuallyForm) Validate(req *http.Request, errs binding.Errors) b
|
||||
type SaveTopicForm struct {
|
||||
Topics []string `binding:"topics;Required;"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@ -77,4 +77,77 @@
|
||||
{{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="{{if and .HasError (not .HTTPSDeployKeyToken)}}tw-hidden {{end}}tw-mb-4" id="add-https-deploy-key-panel">
|
||||
{{if .HTTPSDeployKeyToken}}
|
||||
<div class="ui warning message">
|
||||
<p>{{ctx.Locale.Tr "repo.settings.https_deploy_key_created_info" .HTTPSDeployKeyName}}</p>
|
||||
<input class="tw-w-full tw-mt-2" readonly value="{{.HTTPSDeployKeyToken}}" onclick="this.select()" />
|
||||
</div>
|
||||
{{end}}
|
||||
<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 {{if .Err_Title}}error{{end}}">
|
||||
<label for="https-deploy-key-title">{{ctx.Locale.Tr "repo.settings.title"}}</label>
|
||||
<input id="https-deploy-key-title" name="title" value="{{.httpsKeyTitle}}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox {{if .Err_IsWritable}}error{{end}}">
|
||||
<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" .}}
|
||||
|
||||
278
templates/swagger/v1_json.tmpl
generated
278
templates/swagger/v1_json.tmpl
generated
@ -9493,6 +9493,187 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/https_keys": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "List a repository's HTTPS deploy keys",
|
||||
"operationId": "repoListHTTPSKeys",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "owner of the repo",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repo",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "page number of results to return (1-based)",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "page size of results",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/HTTPSDeployKeyList"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "Add an HTTPS deploy key to a repository",
|
||||
"operationId": "repoCreateHTTPSKey",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "owner of the repo",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repo",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/CreateHTTPSDeployKeyOption"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"$ref": "#/responses/HTTPSDeployKey"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/responses/validationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/https_keys/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "Get a repository's HTTPS deploy key by id",
|
||||
"operationId": "repoGetHTTPSKey",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "owner of the repo",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repo",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "id of the key to get",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/HTTPSDeployKey"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "Delete an HTTPS deploy key from a repository",
|
||||
"operationId": "repoDeleteHTTPSKey",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "owner of the repo",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repo",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "id of the key to delete",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"$ref": "#/responses/empty"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/issue_config": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@ -23714,6 +23895,27 @@
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"CreateHTTPSDeployKeyOption": {
|
||||
"description": "CreateHTTPSDeployKeyOption options when creating an HTTPS deploy key",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Name of the key to add",
|
||||
"type": "string",
|
||||
"uniqueItems": true,
|
||||
"x-go-name": "Name"
|
||||
},
|
||||
"read_only": {
|
||||
"description": "Describe if the key has only read access or read/write",
|
||||
"type": "boolean",
|
||||
"x-go-name": "ReadOnly"
|
||||
}
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"CreateHookOption": {
|
||||
"description": "CreateHookOption options when create a hook",
|
||||
"type": "object",
|
||||
@ -26459,6 +26661,67 @@
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"HTTPSDeployKey": {
|
||||
"description": "HTTPSDeployKey an HTTPS deploy key/token",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"x-go-name": "Created"
|
||||
},
|
||||
"has_recent_activity": {
|
||||
"description": "HasRecentActivity indicates if the key was used in the last 7 days",
|
||||
"type": "boolean",
|
||||
"x-go-name": "HasRecentActivity"
|
||||
},
|
||||
"has_used": {
|
||||
"description": "HasUsed indicates if the key has been used for authentication",
|
||||
"type": "boolean",
|
||||
"x-go-name": "HasUsed"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier for the deploy key",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "ID"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name is the human-readable name for the key",
|
||||
"type": "string",
|
||||
"x-go-name": "Name"
|
||||
},
|
||||
"read_only": {
|
||||
"description": "ReadOnly indicates if the key has read-only access",
|
||||
"type": "boolean",
|
||||
"x-go-name": "ReadOnly"
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "#/definitions/Repository"
|
||||
},
|
||||
"token": {
|
||||
"description": "Token is the plaintext token, only returned on creation",
|
||||
"type": "string",
|
||||
"x-go-name": "Token"
|
||||
},
|
||||
"token_last_eight": {
|
||||
"description": "TokenLastEight is the last 8 characters of the token for identification",
|
||||
"type": "string",
|
||||
"x-go-name": "TokenLastEight"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"x-go-name": "Updated"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL is the API URL for this deploy key",
|
||||
"type": "string",
|
||||
"x-go-name": "URL"
|
||||
}
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"Hook": {
|
||||
"description": "Hook a hook is a web hook when one repository changed",
|
||||
"type": "object",
|
||||
@ -30660,6 +30923,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HTTPSDeployKey": {
|
||||
"description": "HTTPSDeployKey",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/HTTPSDeployKey"
|
||||
}
|
||||
},
|
||||
"HTTPSDeployKeyList": {
|
||||
"description": "HTTPSDeployKeyList",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/HTTPSDeployKey"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Hook": {
|
||||
"description": "Hook",
|
||||
"schema": {
|
||||
|
||||
@ -604,6 +604,29 @@
|
||||
},
|
||||
"description": "GitignoreTemplateList"
|
||||
},
|
||||
"HTTPSDeployKey": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPSDeployKey"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "HTTPSDeployKey"
|
||||
},
|
||||
"HTTPSDeployKeyList": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/HTTPSDeployKey"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "HTTPSDeployKeyList"
|
||||
},
|
||||
"Hook": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@ -3978,6 +4001,27 @@
|
||||
"type": "object",
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"CreateHTTPSDeployKeyOption": {
|
||||
"description": "CreateHTTPSDeployKeyOption options when creating an HTTPS deploy key",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Name of the key to add",
|
||||
"type": "string",
|
||||
"uniqueItems": true,
|
||||
"x-go-name": "Name"
|
||||
},
|
||||
"read_only": {
|
||||
"description": "Describe if the key has only read access or read/write",
|
||||
"type": "boolean",
|
||||
"x-go-name": "ReadOnly"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object",
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"CreateHookOption": {
|
||||
"description": "CreateHookOption options when create a hook",
|
||||
"properties": {
|
||||
@ -6686,6 +6730,68 @@
|
||||
"type": "object",
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"HTTPSDeployKey": {
|
||||
"description": "HTTPSDeployKey an HTTPS deploy key/token",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"x-go-name": "Created"
|
||||
},
|
||||
"has_recent_activity": {
|
||||
"description": "HasRecentActivity indicates if the key was used in the last 7 days",
|
||||
"type": "boolean",
|
||||
"x-go-name": "HasRecentActivity"
|
||||
},
|
||||
"has_used": {
|
||||
"description": "HasUsed indicates if the key has been used for authentication",
|
||||
"type": "boolean",
|
||||
"x-go-name": "HasUsed"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier for the deploy key",
|
||||
"format": "int64",
|
||||
"type": "integer",
|
||||
"x-go-name": "ID"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name is the human-readable name for the key",
|
||||
"type": "string",
|
||||
"x-go-name": "Name"
|
||||
},
|
||||
"read_only": {
|
||||
"description": "ReadOnly indicates if the key has read-only access",
|
||||
"type": "boolean",
|
||||
"x-go-name": "ReadOnly"
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "#/components/schemas/Repository"
|
||||
},
|
||||
"token": {
|
||||
"description": "Token is the plaintext token, only returned on creation",
|
||||
"type": "string",
|
||||
"x-go-name": "Token"
|
||||
},
|
||||
"token_last_eight": {
|
||||
"description": "TokenLastEight is the last 8 characters of the token for identification",
|
||||
"type": "string",
|
||||
"x-go-name": "TokenLastEight"
|
||||
},
|
||||
"updated_at": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"x-go-name": "Updated"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL is the API URL for this deploy key",
|
||||
"format": "uri",
|
||||
"type": "string",
|
||||
"x-go-name": "URL"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"Hook": {
|
||||
"description": "Hook a hook is a web hook when one repository changed",
|
||||
"properties": {
|
||||
@ -20679,6 +20785,202 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/https_keys": {
|
||||
"get": {
|
||||
"operationId": "repoListHTTPSKeys",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "owner of the repo",
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "name of the repo",
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "page number of results to return (1-based)",
|
||||
"in": "query",
|
||||
"name": "page",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "page size of results",
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/HTTPSDeployKeyList"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
}
|
||||
},
|
||||
"summary": "List a repository's HTTPS deploy keys",
|
||||
"tags": [
|
||||
"repository"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "repoCreateHTTPSKey",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "owner of the repo",
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "name of the repo",
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateHTTPSDeployKeyOption"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-originalParamName": "body"
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"$ref": "#/components/responses/HTTPSDeployKey"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/components/responses/validationError"
|
||||
}
|
||||
},
|
||||
"summary": "Add an HTTPS deploy key to a repository",
|
||||
"tags": [
|
||||
"repository"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/https_keys/{id}": {
|
||||
"delete": {
|
||||
"operationId": "repoDeleteHTTPSKey",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "owner of the repo",
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "name of the repo",
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "id of the key to delete",
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"$ref": "#/components/responses/empty"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
}
|
||||
},
|
||||
"summary": "Delete an HTTPS deploy key from a repository",
|
||||
"tags": [
|
||||
"repository"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"operationId": "repoGetHTTPSKey",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "owner of the repo",
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "name of the repo",
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "id of the key to get",
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/HTTPSDeployKey"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
}
|
||||
},
|
||||
"summary": "Get a repository's HTTPS deploy key by id",
|
||||
"tags": [
|
||||
"repository"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/issue_config": {
|
||||
"get": {
|
||||
"operationId": "repoGetIssueConfig",
|
||||
|
||||
201
tests/integration/api_https_keys_test.go
Normal file
201
tests/integration/api_https_keys_test.go
Normal file
@ -0,0 +1,201 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
asymkey_model "code.gitea.io/gitea/models/asymkey"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestViewHTTPSDeployKeysNoLogin(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/https_keys")
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestCreateHTTPSDeployKeyNoLogin(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/https_keys", api.CreateHTTPSDeployKeyOption{
|
||||
Name: "test-key",
|
||||
ReadOnly: true,
|
||||
})
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestGetHTTPSDeployKeyNoLogin(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/https_keys/1")
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestDeleteHTTPSDeployKeyNoLogin(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
req := NewRequest(t, "DELETE", "/api/v1/repos/user2/repo1/https_keys/1")
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestCreateHTTPSDeployKey(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, repoOwner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name)
|
||||
|
||||
createBody := api.CreateHTTPSDeployKeyOption{
|
||||
Name: "ci-read-only",
|
||||
ReadOnly: true,
|
||||
}
|
||||
req := NewRequestWithJSON(t, "POST", keysURL, createBody).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
createdKey := DecodeJSON(t, resp, &api.HTTPSDeployKey{})
|
||||
assert.True(t, createdKey.ReadOnly)
|
||||
assert.Equal(t, "ci-read-only", createdKey.Name)
|
||||
assert.NotEmpty(t, createdKey.Token, "create response must include the plaintext token")
|
||||
assert.NotEmpty(t, createdKey.TokenLastEight)
|
||||
assert.NotEmpty(t, createdKey.URL)
|
||||
assert.Equal(t, createdKey.Token[len(createdKey.Token)-8:], createdKey.TokenLastEight)
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &asymkey_model.HTTPSDeployKey{
|
||||
ID: createdKey.ID,
|
||||
Name: "ci-read-only",
|
||||
})
|
||||
}
|
||||
|
||||
func TestListHTTPSDeployKeys(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, repoOwner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name)
|
||||
|
||||
// Create a key
|
||||
req := NewRequestWithJSON(t, "POST", keysURL, api.CreateHTTPSDeployKeyOption{
|
||||
Name: "list-test-key",
|
||||
ReadOnly: false,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// List should now contain the key
|
||||
req = NewRequest(t, "GET", keysURL).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
keys := DecodeJSON(t, resp, []api.HTTPSDeployKey{})
|
||||
found := false
|
||||
for _, k := range keys {
|
||||
if k.Name == "list-test-key" {
|
||||
found = true
|
||||
assert.False(t, k.ReadOnly)
|
||||
assert.Empty(t, k.Token, "list response must not include the plaintext token")
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "created key should appear in list")
|
||||
}
|
||||
|
||||
func TestGetHTTPSDeployKey(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, repoOwner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name)
|
||||
|
||||
// Create a key
|
||||
createBody := api.CreateHTTPSDeployKeyOption{
|
||||
Name: "get-test-key",
|
||||
ReadOnly: true,
|
||||
}
|
||||
req := NewRequestWithJSON(t, "POST", keysURL, createBody).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
createdKey := DecodeJSON(t, resp, &api.HTTPSDeployKey{})
|
||||
|
||||
// Get by ID
|
||||
getURL := fmt.Sprintf("%s/%d", keysURL, createdKey.ID)
|
||||
req = NewRequest(t, "GET", getURL).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
gotKey := DecodeJSON(t, resp, &api.HTTPSDeployKey{})
|
||||
assert.Equal(t, createdKey.ID, gotKey.ID)
|
||||
assert.Equal(t, "get-test-key", gotKey.Name)
|
||||
assert.True(t, gotKey.ReadOnly)
|
||||
assert.Empty(t, gotKey.Token, "get response must not include the plaintext token")
|
||||
|
||||
// Get non-existent key
|
||||
req = NewRequest(t, "GET", keysURL+"/999999").
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestDeleteHTTPSDeployKey(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, repoOwner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name)
|
||||
|
||||
// Create a key
|
||||
createBody := api.CreateHTTPSDeployKeyOption{
|
||||
Name: "delete-test-key",
|
||||
ReadOnly: true,
|
||||
}
|
||||
req := NewRequestWithJSON(t, "POST", keysURL, createBody).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
createdKey := DecodeJSON(t, resp, &api.HTTPSDeployKey{})
|
||||
|
||||
// Delete
|
||||
deleteURL := fmt.Sprintf("%s/%d", keysURL, createdKey.ID)
|
||||
req = NewRequest(t, "DELETE", deleteURL).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
// Verify deleted
|
||||
req = NewRequest(t, "GET", deleteURL).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestCreateHTTPSDeployKeyDuplicateName(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, repoOwner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/https_keys", repoOwner.Name, repo.Name)
|
||||
|
||||
createBody := api.CreateHTTPSDeployKeyOption{
|
||||
Name: "duplicate-name-key",
|
||||
ReadOnly: true,
|
||||
}
|
||||
req := NewRequestWithJSON(t, "POST", keysURL, createBody).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// Try to create with the same name
|
||||
req = NewRequestWithJSON(t, "POST", keysURL, createBody).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
}
|
||||
135
tests/integration/https_deploy_key_test.go
Normal file
135
tests/integration/https_deploy_key_test.go
Normal 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user