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

feat: harden HTTPS deploy keys and add REST API

Address remaining review issues for HTTPS deploy keys:

- Extract HTTPSDeployKeyTokenLength constant for token validation
- Fix race condition in AddHTTPSDeployKey by moving duplicate check
  inside transaction
- Add timing-resistant dummy hash for invalid-format tokens
- Set explicit timestamps (NoAutoTime) in AddHTTPSDeployKey
- Add REST API CRUD endpoints (/repos/{owner}/{repo}/https_keys)
  with integration tests
- Switch HTTPS deploy key creation to inline token rendering via
  ctx.Data instead of cookie-backed flash (security improvement)
- Improve log.Trace for SSH and HTTPS deploy key create/delete
  handlers (operator, repo, key name, key ID)
- Remove unused DeadlineForm struct
- Fix "bearer token" terminology in auth method comment

Co-Authored-By: Claude Mythos <noreply@anthropic.com>
This commit is contained in:
Claude Mythos 2026-05-06 20:53:50 +08:00
parent ec7608d355
commit 3a5148e450
17 changed files with 1522 additions and 52 deletions

View File

@ -18,6 +18,10 @@ import (
"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
@ -61,7 +65,7 @@ func init() {
// (40 lowercase hex chars). We reject everything else early so that an
// incidental basic-auth password can never collide with the token lookup.
func tokenIsValidFormat(s string) bool {
if len(s) != 40 {
if len(s) != HTTPSDeployKeyTokenLength {
return false
}
for i := 0; i < len(s); i++ {
@ -81,14 +85,6 @@ func AddHTTPSDeployKey(ctx context.Context, repoID int64, name string, readOnly
return nil, "", util.NewInvalidArgumentErrorf("deploy key name must not be empty")
}
has, err := db.GetEngine(ctx).Where("repo_id = ? AND name = ?", repoID, name).Exist(new(HTTPSDeployKey))
if err != nil {
return nil, "", err
}
if has {
return nil, "", ErrHTTPSDeployKeyNameAlreadyUsed{RepoID: repoID, Name: name}
}
salt := util.CryptoRandomString(10)
tokenBytes := util.CryptoRandomBytes(20)
token := hex.EncodeToString(tokenBytes)
@ -98,6 +94,7 @@ func AddHTTPSDeployKey(ctx context.Context, repoID int64, name string, readOnly
mode = perm.AccessModeWrite
}
now := timeutil.TimeStampNow()
key := &HTTPSDeployKey{
RepoID: repoID,
Name: name,
@ -105,9 +102,27 @@ func AddHTTPSDeployKey(ctx context.Context, repoID int64, name string, readOnly
TokenSalt: salt,
TokenLastEight: token[len(token)-8:],
Mode: mode,
CreatedUnix: now,
UpdatedUnix: now,
}
if err := db.Insert(ctx, key); err != nil {
return nil, "", err
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
@ -159,6 +174,7 @@ func DeleteHTTPSDeployKey(ctx context.Context, repoID, id int64) error {
// 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{}
}

View File

@ -4,15 +4,62 @@
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())
@ -23,24 +70,37 @@ func TestAddHTTPSDeployKey(t *testing.T) {
assert.Equal(t, int64(1), key.RepoID)
assert.Equal(t, "ci-readonly", key.Name)
assert.True(t, key.IsReadOnly())
assert.Len(t, token, 40, "token should be a 40-char hex string")
assert.Len(t, token, HTTPSDeployKeyTokenLength, "token should match HTTPSDeployKeyTokenLength")
for _, r := range token {
ok := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')
assert.True(t, ok, "token contains non-hex char %q", r)
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())
_, _, err := AddHTTPSDeployKey(t.Context(), 1, "dup", false)
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)
@ -48,29 +108,44 @@ func TestAddHTTPSDeployKey_NameUnique(t *testing.T) {
"expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", err, err)
// Same name on a different repo is fine.
_, _, err = AddHTTPSDeployKey(t.Context(), 2, "dup", false)
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())
_, _, err := AddHTTPSDeployKey(t.Context(), 1, "a", true)
keyA, _, err := AddHTTPSDeployKey(t.Context(), 1, "t-list-a", true)
require.NoError(t, err)
_, _, err = AddHTTPSDeployKey(t.Context(), 1, "b", false)
defer DeleteHTTPSDeployKey(t.Context(), 1, keyA.ID)
keyB, _, err := AddHTTPSDeployKey(t.Context(), 1, "t-list-b", false)
require.NoError(t, err)
_, _, err = AddHTTPSDeployKey(t.Context(), 2, "c", true)
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)
assert.Len(t, keys, 2)
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)
assert.Len(t, keys, 1)
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) {
@ -113,4 +188,229 @@ func TestVerifyHTTPSDeployToken(t *testing.T) {
_, 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)
}

View File

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

View File

@ -2385,6 +2385,7 @@
"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",

View File

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

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

View File

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

View File

@ -50,6 +50,9 @@ type swaggerParameterBodies struct {
// in:body
CreateKeyOption api.CreateKeyOption
// in:body
CreateHTTPSDeployKeyOption api.CreateHTTPSDeployKeyOption
// in:body
RenameUserOption api.RenameUserOption

View File

@ -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
@ -100,16 +102,20 @@ 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"))
}
@ -117,15 +123,18 @@ func DeleteDeployKey(ctx *context.Context) {
}
// HTTPSDeployKeysPost handles creation of an HTTPS deploy key for the current
// repository. The plaintext token is surfaced to the user exactly once via
// the flash system.
// 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
}
@ -133,26 +142,41 @@ func HTTPSDeployKeysPost(ctx *context.Context) {
if err != nil {
switch {
case asymkey_model.IsErrHTTPSDeployKeyNameAlreadyUsed(err):
ctx.Flash.Error(ctx.Tr("repo.settings.key_name_used"))
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.Redirect(ctx.Repo.RepoLink + "/settings/keys")
ctx.Data["httpsKeyTitle"] = form.Title
DeployKeys(ctx)
ctx.HTML(http.StatusOK, tplDeployKeys)
return
}
log.Trace("HTTPS deploy key added: %d", key.ID)
ctx.Flash.Success(ctx.Tr("repo.settings.https_deploy_key_created", key.Name, token))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys")
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) {
if err := asymkey_model.DeleteHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil {
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"))
}

View File

@ -446,7 +446,10 @@ func TestHTTPSDeployKeyCreateAndDelete(t *testing.T) {
IsWritable: true,
})
HTTPSDeployKeysPost(ctx)
assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus())
// 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})
@ -455,10 +458,10 @@ func TestHTTPSDeployKeyCreateAndDelete(t *testing.T) {
assert.Equal(t, "ci-writable", keys[0].Name)
assert.False(t, keys[0].IsReadOnly())
// The plaintext token must be stashed on the flash for one-time display.
flash := ctx.Flash
require.NotNil(t, flash)
assert.Contains(t, flash.SuccessMsg, "ci-writable")
// 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")
@ -473,3 +476,31 @@ func TestHTTPSDeployKeyCreateAndDelete(t *testing.T) {
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)
}

View File

@ -19,7 +19,7 @@ var _ Method = &HTTPSDeployToken{}
// HTTPSDeployToken authenticates HTTP Basic-auth credentials whose token half
// matches a row in the https_deploy_key table. It is deliberately *not*
// registered globally: callers add it to an auth group only for request
// contexts where a repo-scoped bearer token makes sense (currently the git
// 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{}

View File

@ -699,6 +699,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{

View File

@ -735,17 +735,6 @@ type SaveTopicForm struct {
Topics []string `binding:"topics;Required;"`
}
// DeadlineForm hold the validation rules for deadlines
type DeadlineForm struct {
DateString string `form:"date" binding:"Required;Size(10)"`
}
// Validate validates the fields
func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// HTTPSDeployKeyForm form for adding an HTTPS deploy key to a repository.
type HTTPSDeployKeyForm struct {
Title string `binding:"Required;MaxSize(50)"`

View File

@ -85,17 +85,23 @@
</div>
</h4>
<div class="ui attached segment">
<div class="tw-hidden tw-mb-4" id="add-https-deploy-key-panel">
<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">
<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" required>
<input id="https-deploy-key-title" name="title" value="{{.httpsKeyTitle}}" required>
</div>
<div class="field">
<div class="ui checkbox">
<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"}}

View File

@ -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": {

View File

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

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