mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-04-12 02:02:14 +00:00
feat(activitiypub): enable HTTP signatures on all ActivityPub endpoints (#7035)
- Set the right keyID and use the right signing keys for outgoing requests. - Verify the HTTP signature of all incoming requests, except for the server actor. - Caches keys of incoming requests for users and servers actors. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7035 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: famfo <famfo@famfo.xyz> Co-committed-by: famfo <famfo@famfo.xyz>
This commit is contained in:
parent
ba5b157f7e
commit
77b0275572
22 changed files with 681 additions and 122 deletions
|
@ -4,6 +4,7 @@
|
|||
package forgefed
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -15,12 +16,14 @@ import (
|
|||
// FederationHost data type
|
||||
// swagger:model
|
||||
type FederationHost struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
|
||||
NodeInfo NodeInfo `xorm:"extends NOT NULL"`
|
||||
LatestActivity time.Time `xorm:"NOT NULL"`
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
|
||||
NodeInfo NodeInfo `xorm:"extends NOT NULL"`
|
||||
LatestActivity time.Time `xorm:"NOT NULL"`
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
KeyID sql.NullString `xorm:"key_id UNIQUE"`
|
||||
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
|
||||
}
|
||||
|
||||
// Factory function for FederationHost. Created struct is asserted to be valid.
|
||||
|
|
|
@ -30,9 +30,9 @@ func GetFederationHost(ctx context.Context, ID int64) (*FederationHost, error) {
|
|||
return host, nil
|
||||
}
|
||||
|
||||
func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) {
|
||||
func findFederationHostFromDB(ctx context.Context, searchKey, searchValue string) (*FederationHost, error) {
|
||||
host := new(FederationHost)
|
||||
has, err := db.GetEngine(ctx).Where("host_fqdn=?", strings.ToLower(fqdn)).Get(host)
|
||||
has, err := db.GetEngine(ctx).Where(searchKey, searchValue).Get(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
|
@ -44,6 +44,14 @@ func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost
|
|||
return host, nil
|
||||
}
|
||||
|
||||
func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) {
|
||||
return findFederationHostFromDB(ctx, "host_fqdn=?", strings.ToLower(fqdn))
|
||||
}
|
||||
|
||||
func FindFederationHostByKeyID(ctx context.Context, keyID string) (*FederationHost, error) {
|
||||
return findFederationHostFromDB(ctx, "key_id=?", keyID)
|
||||
}
|
||||
|
||||
func CreateFederationHost(ctx context.Context, host *FederationHost) error {
|
||||
if res, err := validation.IsValid(host); !res {
|
||||
return err
|
||||
|
|
|
@ -94,6 +94,8 @@ var migrations = []*Migration{
|
|||
NewMigration("Add `created_unix` column to `user_redirect` table", AddCreatedUnixToRedirect),
|
||||
// v27 -> v28
|
||||
NewMigration("Add pronoun privacy settings to user", AddHidePronounsOptionToUser),
|
||||
// v28 -> v29
|
||||
NewMigration("Add public key information to `FederatedUser` and `FederationHost`", AddPublicKeyInformationForFederation),
|
||||
}
|
||||
|
||||
// GetCurrentDBVersion returns the current Forgejo database version.
|
||||
|
|
29
models/forgejo_migrations/v29.go
Normal file
29
models/forgejo_migrations/v29.go
Normal file
|
@ -0,0 +1,29 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package forgejo_migrations //nolint:revive
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddPublicKeyInformationForFederation(x *xorm.Engine) error {
|
||||
type FederationHost struct {
|
||||
KeyID sql.NullString `xorm:"key_id UNIQUE"`
|
||||
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
|
||||
}
|
||||
|
||||
err := x.Sync(&FederationHost{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type FederatedUser struct {
|
||||
KeyID sql.NullString `xorm:"key_id UNIQUE"`
|
||||
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
|
||||
}
|
||||
|
||||
return x.Sync(&FederatedUser{})
|
||||
}
|
44
models/user/activitypub.go
Normal file
44
models/user/activitypub.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/validation"
|
||||
)
|
||||
|
||||
// APActorID returns the IRI to the api endpoint of the user
|
||||
func (u *User) APActorID() string {
|
||||
if u.IsAPServerActor() {
|
||||
return fmt.Sprintf("%sapi/v1/activitypub/actor", setting.AppURL)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%sapi/v1/activitypub/user-id/%s", setting.AppURL, url.PathEscape(fmt.Sprintf("%d", u.ID)))
|
||||
}
|
||||
|
||||
// APActorKeyID returns the ID of the user's public key
|
||||
func (u *User) APActorKeyID() string {
|
||||
return u.APActorID() + "#main-key"
|
||||
}
|
||||
|
||||
func GetUserByFederatedURI(ctx context.Context, federatedURI string) (*User, error) {
|
||||
user := new(User)
|
||||
has, err := db.GetEngine(ctx).Where("normalized_federated_uri=?", federatedURI).Get(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if res, err := validation.IsValid(*user); !res {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
|
@ -4,14 +4,20 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/modules/validation"
|
||||
)
|
||||
|
||||
type FederatedUser struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"NOT NULL"`
|
||||
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
|
||||
FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"NOT NULL"`
|
||||
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
|
||||
FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
|
||||
KeyID sql.NullString `xorm:"key_id UNIQUE"`
|
||||
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
|
||||
}
|
||||
|
||||
func NewFederatedUser(userID int64, externalID string, federationHostID int64) (FederatedUser, error) {
|
||||
|
@ -26,6 +32,30 @@ func NewFederatedUser(userID int64, externalID string, federationHostID int64) (
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func getFederatedUserFromDB(ctx context.Context, searchKey, searchValue any) (*FederatedUser, error) {
|
||||
federatedUser := new(FederatedUser)
|
||||
has, err := db.GetEngine(ctx).Where(searchKey, searchValue).Get(federatedUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if res, err := validation.IsValid(*federatedUser); !res {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return federatedUser, nil
|
||||
}
|
||||
|
||||
func GetFederatedUserByKeyID(ctx context.Context, keyID string) (*FederatedUser, error) {
|
||||
return getFederatedUserFromDB(ctx, "key_id=?", keyID)
|
||||
}
|
||||
|
||||
func GetFederatedUserByUserID(ctx context.Context, userID int64) (*FederatedUser, error) {
|
||||
return getFederatedUserFromDB(ctx, "user_id=?", userID)
|
||||
}
|
||||
|
||||
func (user FederatedUser) Validate() []string {
|
||||
var result []string
|
||||
result = append(result, validation.ValidateNotEmpty(user.UserID, "UserID")...)
|
||||
|
|
|
@ -311,11 +311,6 @@ func (u *User) HTMLURL() string {
|
|||
return setting.AppURL + url.PathEscape(u.Name)
|
||||
}
|
||||
|
||||
// APActorID returns the IRI to the api endpoint of the user
|
||||
func (u *User) APActorID() string {
|
||||
return fmt.Sprintf("%vapi/v1/activitypub/user-id/%v", setting.AppURL, url.PathEscape(fmt.Sprintf("%v", u.ID)))
|
||||
}
|
||||
|
||||
// OrganisationLink returns the organization sub page link.
|
||||
func (u *User) OrganisationLink() string {
|
||||
return setting.AppSubURL + "/org/" + url.PathEscape(u.Name)
|
||||
|
|
|
@ -73,30 +73,30 @@ func (u *User) IsActions() bool {
|
|||
}
|
||||
|
||||
const (
|
||||
APActorUserID = -3
|
||||
APActorUserName = "actor"
|
||||
APActorEmail = "noreply@forgejo.org"
|
||||
APServerActorUserID = -3
|
||||
APServerActorUserName = "actor"
|
||||
APServerActorEmail = "noreply@forgejo.org"
|
||||
)
|
||||
|
||||
func NewAPActorUser() *User {
|
||||
func NewAPServerActor() *User {
|
||||
return &User{
|
||||
ID: APActorUserID,
|
||||
Name: APActorUserName,
|
||||
LowerName: APActorUserName,
|
||||
ID: APServerActorUserID,
|
||||
Name: APServerActorUserName,
|
||||
LowerName: APServerActorUserName,
|
||||
IsActive: true,
|
||||
Email: APActorEmail,
|
||||
Email: APServerActorEmail,
|
||||
KeepEmailPrivate: true,
|
||||
LoginName: APActorUserName,
|
||||
LoginName: APServerActorUserName,
|
||||
Type: UserTypeIndividual,
|
||||
Visibility: structs.VisibleTypePublic,
|
||||
}
|
||||
}
|
||||
|
||||
func APActorUserAPActorID() string {
|
||||
func APServerActorID() string {
|
||||
path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor")
|
||||
return path
|
||||
}
|
||||
|
||||
func (u *User) IsAPActor() bool {
|
||||
return u != nil && u.ID == APActorUserID
|
||||
func (u *User) IsAPServerActor() bool {
|
||||
return u != nil && u.ID == APServerActorUserID
|
||||
}
|
||||
|
|
|
@ -139,9 +139,21 @@ func TestAPActorID(t *testing.T) {
|
|||
user := user_model.User{ID: 1}
|
||||
url := user.APActorID()
|
||||
expected := "https://try.gitea.io/api/v1/activitypub/user-id/1"
|
||||
if url != expected {
|
||||
t.Errorf("unexpected APActorID, expected: %q, actual: %q", expected, url)
|
||||
}
|
||||
assert.Equal(t, expected, url)
|
||||
}
|
||||
|
||||
func TestAPActorID_APActorID(t *testing.T) {
|
||||
user := user_model.User{ID: user_model.APServerActorUserID}
|
||||
url := user.APActorID()
|
||||
expected := "https://try.gitea.io/api/v1/activitypub/actor"
|
||||
assert.Equal(t, expected, url)
|
||||
}
|
||||
|
||||
func TestAPActorKeyID(t *testing.T) {
|
||||
user := user_model.User{ID: 1}
|
||||
url := user.APActorKeyID()
|
||||
expected := "https://try.gitea.io/api/v1/activitypub/user-id/1#main-key"
|
||||
assert.Equal(t, expected, url)
|
||||
}
|
||||
|
||||
func TestSearchUsers(t *testing.T) {
|
||||
|
|
|
@ -191,10 +191,17 @@ func (c *Client) GetBody(uri string) ([]byte, error) {
|
|||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if response.ContentLength > setting.Federation.MaxSize {
|
||||
return nil, fmt.Errorf("Request returned %d bytes (max allowed incomming size: %d bytes)", response.ContentLength, setting.Federation.MaxSize)
|
||||
} else if response.ContentLength == -1 {
|
||||
log.Warn("Request to %v returned an unknown content length, response may be truncated to %d bytes", uri, setting.Federation.MaxSize)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, setting.Federation.MaxSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug("Client: got body: %v", charLimiter(string(body), 120))
|
||||
return body, nil
|
||||
}
|
||||
|
|
|
@ -15,18 +15,20 @@ var (
|
|||
Enabled bool
|
||||
ShareUserStatistics bool
|
||||
MaxSize int64
|
||||
Algorithms []string
|
||||
SignatureAlgorithms []string
|
||||
DigestAlgorithm string
|
||||
GetHeaders []string
|
||||
PostHeaders []string
|
||||
SignatureEnforced bool
|
||||
}{
|
||||
Enabled: false,
|
||||
ShareUserStatistics: true,
|
||||
MaxSize: 4,
|
||||
Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
|
||||
SignatureAlgorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
|
||||
DigestAlgorithm: "SHA-256",
|
||||
GetHeaders: []string{"(request-target)", "Date", "Host"},
|
||||
PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"},
|
||||
SignatureEnforced: true,
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -44,8 +46,8 @@ func loadFederationFrom(rootCfg ConfigProvider) {
|
|||
// Get MaxSize in bytes instead of MiB
|
||||
Federation.MaxSize = 1 << 20 * Federation.MaxSize
|
||||
|
||||
HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.Algorithms))
|
||||
for i, alg := range Federation.Algorithms {
|
||||
HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.SignatureAlgorithms))
|
||||
for i, alg := range Federation.SignatureAlgorithms {
|
||||
HttpsigAlgs[i] = httpsig.Algorithm(alg)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,7 +95,7 @@ func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server {
|
|||
})
|
||||
}
|
||||
for _, repository := range mock.Repositories {
|
||||
federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox/", repository.ID),
|
||||
federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox", repository.ID),
|
||||
func(res http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != "POST" {
|
||||
t.Errorf("POST expected at: %q", req.URL.EscapedPath())
|
||||
|
|
|
@ -28,7 +28,7 @@ func Actor(ctx *context.APIContext) {
|
|||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
link := user_model.APActorUserAPActorID()
|
||||
link := user_model.APServerActorID()
|
||||
actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType)
|
||||
|
||||
actor.PreferredUsername = ap.NaturalLanguageValuesNew()
|
||||
|
@ -46,7 +46,7 @@ func Actor(ctx *context.APIContext) {
|
|||
actor.PublicKey.ID = ap.IRI(link + "#main-key")
|
||||
actor.PublicKey.Owner = ap.IRI(link)
|
||||
|
||||
publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPActorUser())
|
||||
publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPServerActor())
|
||||
if err != nil {
|
||||
ctx.ServerError("GetPublicKey", err)
|
||||
return
|
||||
|
|
|
@ -6,59 +6,141 @@ package activitypub
|
|||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/models/forgefed"
|
||||
"forgejo.org/models/user"
|
||||
"forgejo.org/modules/activitypub"
|
||||
"forgejo.org/modules/httplib"
|
||||
fm "forgejo.org/modules/forgefed"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/setting"
|
||||
gitea_context "forgejo.org/services/context"
|
||||
"forgejo.org/services/federation"
|
||||
|
||||
"github.com/42wim/httpsig"
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) {
|
||||
person := ap.PersonNew(ap.IRI(keyID.String()))
|
||||
err = person.UnmarshalJSON(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err)
|
||||
}
|
||||
pubKey := person.PublicKey
|
||||
if pubKey.ID.String() != keyID.String() {
|
||||
return nil, fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b))
|
||||
}
|
||||
pubKeyPem := pubKey.PublicKeyPem
|
||||
func decodePublicKeyPem(pubKeyPem string) ([]byte, error) {
|
||||
block, _ := pem.Decode([]byte(pubKeyPem))
|
||||
if block == nil || block.Type != "PUBLIC KEY" {
|
||||
return nil, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type")
|
||||
}
|
||||
p, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||
return p, err
|
||||
|
||||
return block.Bytes, nil
|
||||
}
|
||||
|
||||
func fetch(iri *url.URL) (b []byte, err error) {
|
||||
req := httplib.NewRequest(iri.String(), http.MethodGet)
|
||||
req.Header("Accept", activitypub.ActivityStreamsContentType)
|
||||
req.Header("User-Agent", "Gitea/"+setting.AppVer)
|
||||
resp, err := req.Response()
|
||||
func getFederatedUser(ctx *gitea_context.APIContext, person *ap.Person, federationHost *forgefed.FederationHost) (*user.FederatedUser, error) {
|
||||
dbUser, err := user.GetUserByFederatedURI(ctx, person.ID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
|
||||
if dbUser != nil {
|
||||
federatedUser, err := user.GetFederatedUserByUserID(ctx, dbUser.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if federatedUser != nil {
|
||||
return federatedUser, nil
|
||||
}
|
||||
}
|
||||
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
|
||||
return b, err
|
||||
|
||||
personID, err := fm.NewPersonID(person.ID.String(), string(federationHost.NodeInfo.SoftwareName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, federatedUser, err := federation.CreateUserFromAP(ctx, personID, federationHost.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return federatedUser, nil
|
||||
}
|
||||
|
||||
func storePublicKey(ctx *gitea_context.APIContext, person *ap.Person, pubKeyBytes []byte) error {
|
||||
federationHost, err := federation.GetFederationHostForURI(ctx, person.ID.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if person.Type == ap.ActivityVocabularyType("Application") {
|
||||
federationHost.KeyID = sql.NullString{
|
||||
String: person.PublicKey.ID.String(),
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
federationHost.PublicKey = sql.Null[sql.RawBytes]{
|
||||
V: pubKeyBytes,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).ID(federationHost.ID).Update(federationHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if person.Type == ap.ActivityVocabularyType("Person") {
|
||||
federatedUser, err := getFederatedUser(ctx, person, federationHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
federatedUser.KeyID = sql.NullString{
|
||||
String: person.PublicKey.ID.String(),
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
federatedUser.PublicKey = sql.Null[sql.RawBytes]{
|
||||
V: pubKeyBytes,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).ID(federatedUser.ID).Update(federatedUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPublicKeyFromResponse(b []byte, keyID *url.URL) (person *ap.Person, pubKeyBytes []byte, p crypto.PublicKey, err error) {
|
||||
person = ap.PersonNew(ap.IRI(keyID.String()))
|
||||
err = person.UnmarshalJSON(b)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err)
|
||||
}
|
||||
|
||||
pubKey := person.PublicKey
|
||||
if pubKey.ID.String() != keyID.String() {
|
||||
return nil, nil, nil, fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b))
|
||||
}
|
||||
|
||||
pubKeyBytes, err = decodePublicKeyPem(pubKey.PublicKeyPem)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
p, err = x509.ParsePKIXPublicKey(pubKeyBytes)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return person, pubKeyBytes, p, err
|
||||
}
|
||||
|
||||
func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) {
|
||||
if !setting.Federation.SignatureEnforced {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
r := ctx.Req
|
||||
|
||||
// 1. Figure out what key we need to verify
|
||||
|
@ -66,23 +148,78 @@ func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, er
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
ID := v.KeyId()
|
||||
idIRI, err := url.Parse(ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
signatureAlgorithm := httpsig.Algorithm(setting.Federation.SignatureAlgorithms[0])
|
||||
|
||||
// 2. Fetch the public key of the other actor
|
||||
b, err := fetch(idIRI)
|
||||
// Try if the signing actor is an already known federated user
|
||||
federationUser, err := user.GetFederatedUserByKeyID(ctx, idIRI.String())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pubKey, err := getPublicKeyFromResponse(b, idIRI)
|
||||
|
||||
if federationUser != nil && federationUser.PublicKey.Valid {
|
||||
pubKey, err := x509.ParsePKIXPublicKey(federationUser.PublicKey.V)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
authenticated = v.Verify(pubKey, signatureAlgorithm) == nil
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
// Try if the signing actor is an already known federation host
|
||||
federationHost, err := forgefed.FindFederationHostByKeyID(ctx, idIRI.String())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 3. Verify the other actor's key
|
||||
algo := httpsig.Algorithm(setting.Federation.Algorithms[0])
|
||||
authenticated = v.Verify(pubKey, algo) == nil
|
||||
|
||||
if federationHost != nil && federationHost.PublicKey.Valid {
|
||||
pubKey, err := x509.ParsePKIXPublicKey(federationHost.PublicKey.V)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
authenticated = v.Verify(pubKey, signatureAlgorithm) == nil
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
// Fetch missing public key
|
||||
actionsUser := user.NewAPServerActor()
|
||||
clientFactory, err := activitypub.GetClientFactory(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
b, err := apClient.GetBody(idIRI.String())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
person, pubKeyBytes, pubKey, err := getPublicKeyFromResponse(b, idIRI)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
authenticated = v.Verify(pubKey, signatureAlgorithm) == nil
|
||||
if authenticated {
|
||||
err = storePublicKey(ctx, person, pubKeyBytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
|
|
|
@ -840,22 +840,22 @@ func Routes() *web.Route {
|
|||
m.Group("/activitypub", func() {
|
||||
// deprecated, remove in 1.20, use /user-id/{user-id} instead
|
||||
m.Group("/user/{username}", func() {
|
||||
m.Get("", activitypub.Person)
|
||||
m.Get("", activitypub.ReqHTTPSignature(), activitypub.Person)
|
||||
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
|
||||
}, context.UserAssignmentAPI(), checkTokenPublicOnly())
|
||||
m.Group("/user-id/{user-id}", func() {
|
||||
m.Get("", activitypub.Person)
|
||||
m.Get("", activitypub.ReqHTTPSignature(), activitypub.Person)
|
||||
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
|
||||
}, context.UserIDAssignmentAPI(), checkTokenPublicOnly())
|
||||
m.Group("/actor", func() {
|
||||
m.Get("", activitypub.Actor)
|
||||
m.Post("/inbox", activitypub.ActorInbox)
|
||||
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.ActorInbox)
|
||||
})
|
||||
m.Group("/repository-id/{repository-id}", func() {
|
||||
m.Get("", activitypub.Repository)
|
||||
m.Get("", activitypub.ReqHTTPSignature(), activitypub.Repository)
|
||||
m.Post("/inbox",
|
||||
bind(forgefed.ForgeLike{}),
|
||||
// TODO: activitypub.ReqHTTPSignature(),
|
||||
activitypub.ReqHTTPSignature(),
|
||||
activitypub.RepositoryInbox)
|
||||
}, context.RepositoryIDAssignmentAPI())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub))
|
||||
|
|
|
@ -1313,7 +1313,7 @@ func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *use
|
|||
}
|
||||
|
||||
// Special user that can't have associated contributions and permissions in the repo.
|
||||
if poster.IsGhost() || poster.IsActions() || poster.IsAPActor() {
|
||||
if poster.IsGhost() || poster.IsActions() || poster.IsAPServerActor() {
|
||||
return roleDescriptor, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -98,39 +98,47 @@ func ProcessLikeActivity(ctx context.Context, form any, repositoryID int64) (int
|
|||
}
|
||||
|
||||
func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) {
|
||||
actionsUser := user.NewActionsUser()
|
||||
actionsUser := user.NewAPServerActor()
|
||||
clientFactory, err := activitypub.GetClientFactory(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := clientFactory.WithKeys(ctx, actionsUser, "no idea where to get key material.")
|
||||
|
||||
client, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := client.GetBody(actorID.AsWellKnownNodeInfoURI())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodeInfoWellKnown, err := forgefed.NewNodeInfoWellKnown(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err = client.GetBody(nodeInfoWellKnown.Href)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodeInfo, err := forgefed.NewNodeInfo(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := forgefed.NewFederationHost(nodeInfo, actorID.Host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = forgefed.CreateFederationHost(ctx, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
|
@ -155,18 +163,18 @@ func GetFederationHostForURI(ctx context.Context, actorURI string) (*forgefed.Fe
|
|||
}
|
||||
|
||||
func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, error) {
|
||||
// ToDo: Do we get a publicKeyId from server, repo or owner or repo?
|
||||
actionsUser := user.NewActionsUser()
|
||||
actionsUser := user.NewAPServerActor()
|
||||
clientFactory, err := activitypub.GetClientFactory(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err := clientFactory.WithKeys(ctx, actionsUser, "no idea where to get key material.")
|
||||
|
||||
apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
body, err := client.GetBody(personID.AsURI())
|
||||
body, err := apClient.GetBody(personID.AsURI())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
@ -176,26 +184,32 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
|
|||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if res, err := validation.IsValid(person); !res {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
log.Info("Fetched valid person:%q", person)
|
||||
|
||||
localFqdn, err := url.ParseRequestURI(setting.AppURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
email := fmt.Sprintf("f%v@%v", uuid.New().String(), localFqdn.Hostname())
|
||||
loginName := personID.AsLoginName()
|
||||
name := fmt.Sprintf("%v%v", person.PreferredUsername.String(), personID.HostSuffix())
|
||||
fullName := person.Name.String()
|
||||
|
||||
if len(person.Name) == 0 {
|
||||
fullName = name
|
||||
}
|
||||
|
||||
password, err := password.Generate(32)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
newUser := user.User{
|
||||
LowerName: strings.ToLower(name),
|
||||
Name: name,
|
||||
|
@ -209,16 +223,18 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
|
|||
IsAdmin: false,
|
||||
NormalizedFederatedURI: personID.AsURI(),
|
||||
}
|
||||
|
||||
federatedUser := user.FederatedUser{
|
||||
ExternalID: personID.ID,
|
||||
FederationHostID: federationHostID,
|
||||
}
|
||||
|
||||
err = user.CreateFederatedUser(ctx, &newUser, &federatedUser)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
log.Info("Created federatedUser:%q", federatedUser)
|
||||
|
||||
log.Info("Created federatedUser:%q", federatedUser)
|
||||
return &newUser, &federatedUser, nil
|
||||
}
|
||||
|
||||
|
@ -274,7 +290,8 @@ func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorID())
|
||||
|
||||
apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorKeyID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -285,7 +302,7 @@ func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error
|
|||
return err
|
||||
}
|
||||
|
||||
_, err = apclient.Post(json, fmt.Sprintf("%v/inbox/", activity.Object))
|
||||
_, err = apclient.Post(json, fmt.Sprintf("%s/inbox", activity.Object))
|
||||
if err != nil {
|
||||
log.Error("error %v while sending activity: %q", err, activity)
|
||||
}
|
||||
|
|
54
tests/integration/activitypub_client_test.go
Normal file
54
tests/integration/activitypub_client_test.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/activitypub"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/routers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActivityPubClientBodySize(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
clientFactory, err := activitypub.GetClientFactory(db.DefaultContext)
|
||||
require.NoError(t, err)
|
||||
|
||||
apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID())
|
||||
require.NoError(t, err)
|
||||
|
||||
url := u.JoinPath("/api/v1/nodeinfo").String()
|
||||
|
||||
// Request with normal MaxSize
|
||||
t.Run("NormalMaxSize", func(t *testing.T) {
|
||||
resp, err := apClient.GetBody(url)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(resp), "forgejo")
|
||||
})
|
||||
|
||||
// Set MaxSize to something very low to always fail
|
||||
// Request with low MaxSize
|
||||
t.Run("LowMaxSize", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.MaxSize, 100)()
|
||||
|
||||
_, err = apClient.GetBody(url)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "Request returned")
|
||||
})
|
||||
})
|
||||
}
|
|
@ -26,33 +26,47 @@ import (
|
|||
func TestActivityPubPerson(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
userID := 2
|
||||
username := "user2"
|
||||
userURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", u, userID)
|
||||
|
||||
userID := 2
|
||||
username := "user2"
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/user-id/%v", userID))
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "@context")
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
var person ap.Person
|
||||
err := person.UnmarshalJSON(resp.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
clientFactory, err := activitypub.GetClientFactory(db.DefaultContext)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, ap.PersonType, person.Type)
|
||||
assert.Equal(t, username, person.PreferredUsername.String())
|
||||
keyID := person.GetID().String()
|
||||
assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v$", userID), keyID)
|
||||
assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/outbox$", userID), person.Outbox.GetID().String())
|
||||
assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/inbox$", userID), person.Inbox.GetID().String())
|
||||
apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID())
|
||||
require.NoError(t, err)
|
||||
|
||||
pubKey := person.PublicKey
|
||||
assert.NotNil(t, pubKey)
|
||||
publicKeyID := keyID + "#main-key"
|
||||
assert.Equal(t, pubKey.ID.String(), publicKeyID)
|
||||
// Unsigned request
|
||||
t.Run("UnsignedRequest", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", userURL)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
pubKeyPem := pubKey.PublicKeyPem
|
||||
assert.NotNil(t, pubKeyPem)
|
||||
assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem)
|
||||
t.Run("SignedRequestValidation", func(t *testing.T) {
|
||||
// Signed requset
|
||||
resp, err := apClient.GetBody(userURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
var person ap.Person
|
||||
err = person.UnmarshalJSON(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, ap.PersonType, person.Type)
|
||||
assert.Equal(t, username, person.PreferredUsername.String())
|
||||
assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d$", userID), person.GetID())
|
||||
assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/outbox$", userID), person.Outbox.GetID().String())
|
||||
assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/inbox$", userID), person.Inbox.GetID().String())
|
||||
|
||||
assert.NotNil(t, person.PublicKey)
|
||||
assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d#main-key$", userID), person.PublicKey.ID)
|
||||
|
||||
assert.NotNil(t, person.PublicKey.PublicKeyPem)
|
||||
assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", person.PublicKey.PublicKeyPem)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityPubMissingPerson(t *testing.T) {
|
||||
|
|
|
@ -28,18 +28,28 @@ import (
|
|||
func TestActivityPubRepository(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repositoryID := 2
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID))
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "@context")
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
repositoryID := 2
|
||||
|
||||
var repository forgefed_modules.Repository
|
||||
err := repository.UnmarshalJSON(resp.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
apServerActor := user.NewAPServerActor()
|
||||
|
||||
assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%v$", repositoryID), repository.GetID().String())
|
||||
cf, err := activitypub.GetClientFactory(db.DefaultContext)
|
||||
require.NoError(t, err)
|
||||
|
||||
c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID())
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := c.GetBody(fmt.Sprintf("%sapi/v1/activitypub/repository-id/%d", u, repositoryID))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(resp), "@context")
|
||||
|
||||
var repository forgefed_modules.Repository
|
||||
err = repository.UnmarshalJSON(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%d$", repositoryID), repository.GetID().String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityPubMissingRepository(t *testing.T) {
|
||||
|
@ -48,7 +58,7 @@ func TestActivityPubMissingRepository(t *testing.T) {
|
|||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repositoryID := 9999999
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID))
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%d", repositoryID))
|
||||
resp := MakeRequest(t, req, http.StatusNotFound)
|
||||
assert.Contains(t, resp.Body.String(), "repository does not exist")
|
||||
}
|
||||
|
@ -62,14 +72,16 @@ func TestActivityPubRepositoryInboxValid(t *testing.T) {
|
|||
defer federatedSrv.Close()
|
||||
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
actionsUser := user.NewActionsUser()
|
||||
apServerActor := user.NewAPServerActor()
|
||||
repositoryID := 2
|
||||
timeNow := time.Now().UTC()
|
||||
|
||||
cf, err := activitypub.GetClientFactory(db.DefaultContext)
|
||||
require.NoError(t, err)
|
||||
c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used")
|
||||
|
||||
c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID())
|
||||
require.NoError(t, err)
|
||||
|
||||
repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String()
|
||||
|
||||
activity1 := []byte(fmt.Sprintf(
|
||||
|
@ -139,14 +151,16 @@ func TestActivityPubRepositoryInboxInvalid(t *testing.T) {
|
|||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
actionsUser := user.NewActionsUser()
|
||||
apServerActor := user.NewAPServerActor()
|
||||
repositoryID := 2
|
||||
|
||||
cf, err := activitypub.GetClientFactory(db.DefaultContext)
|
||||
require.NoError(t, err)
|
||||
c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used")
|
||||
|
||||
c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID())
|
||||
require.NoError(t, err)
|
||||
|
||||
repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox", repositoryID)).String()
|
||||
repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String()
|
||||
activity := []byte(`{"type":"Wrong"}`)
|
||||
resp, err := c.Post(activity, repoInboxURL)
|
||||
require.NoError(t, err)
|
||||
|
|
82
tests/integration/api_federation_httpsig_test.go
Normal file
82
tests/integration/api_federation_httpsig_test.go
Normal file
|
@ -0,0 +1,82 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/models/forgefed"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/models/user"
|
||||
"forgejo.org/modules/activitypub"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/routers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFederationHttpSigValidation(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
userID := 2
|
||||
userURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", u, userID)
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user.User{ID: 1})
|
||||
|
||||
clientFactory, err := activitypub.GetClientFactory(db.DefaultContext)
|
||||
require.NoError(t, err)
|
||||
|
||||
apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unsigned request
|
||||
t.Run("UnsignedRequest", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", userURL)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
// Signed request
|
||||
t.Run("SignedRequest", func(t *testing.T) {
|
||||
resp, err := apClient.Get(userURL)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
// HACK HACK HACK: the host part of the URL gets set to which IP forgejo is
|
||||
// listening on, NOT localhost, which is the Domain given to forgejo which
|
||||
// is then used for eg. the keyID all requests
|
||||
applicationKeyID := fmt.Sprintf("%sapi/v1/activitypub/actor#main-key", setting.AppURL)
|
||||
actorKeyID := fmt.Sprintf("%sapi/v1/activitypub/user-id/1#main-key", setting.AppURL)
|
||||
|
||||
// Check for cached public keys
|
||||
t.Run("ValidateCaches", func(t *testing.T) {
|
||||
host, err := forgefed.FindFederationHostByKeyID(db.DefaultContext, applicationKeyID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, host)
|
||||
assert.True(t, host.PublicKey.Valid)
|
||||
|
||||
user, err := user.GetFederatedUserByKeyID(db.DefaultContext, actorKeyID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, user)
|
||||
assert.True(t, user.PublicKey.Valid)
|
||||
})
|
||||
|
||||
// Disable signature validation
|
||||
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
|
||||
|
||||
// Unsigned request
|
||||
t.Run("SignatureValidationDisabled", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", userURL)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
})
|
||||
})
|
||||
}
|
109
tests/integration/user_federationhost_xorm_test.go
Normal file
109
tests/integration/user_federationhost_xorm_test.go
Normal file
|
@ -0,0 +1,109 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/models/forgefed"
|
||||
"forgejo.org/models/user"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStoreFederationHost(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
t.Run("ExplicitNull", func(t *testing.T) {
|
||||
federationHost := forgefed.FederationHost{
|
||||
HostFqdn: "ExplicitNull",
|
||||
// Explicit null on KeyID and PublicKey
|
||||
KeyID: sql.NullString{Valid: false},
|
||||
PublicKey: sql.Null[sql.RawBytes]{Valid: false},
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(db.DefaultContext).Insert(&federationHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbFederationHost := new(forgefed.FederationHost)
|
||||
has, err := db.GetEngine(db.DefaultContext).Where("host_fqdn=?", "ExplicitNull").Get(dbFederationHost)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, has)
|
||||
|
||||
assert.False(t, dbFederationHost.KeyID.Valid)
|
||||
assert.False(t, dbFederationHost.PublicKey.Valid)
|
||||
})
|
||||
|
||||
t.Run("NotNull", func(t *testing.T) {
|
||||
federationHost := forgefed.FederationHost{
|
||||
HostFqdn: "ImplicitNull",
|
||||
KeyID: sql.NullString{Valid: true, String: "meow"},
|
||||
PublicKey: sql.Null[sql.RawBytes]{Valid: true, V: sql.RawBytes{0x23, 0x42}},
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(db.DefaultContext).Insert(&federationHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbFederationHost := new(forgefed.FederationHost)
|
||||
has, err := db.GetEngine(db.DefaultContext).Where("host_fqdn=?", "ImplicitNull").Get(dbFederationHost)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, has)
|
||||
|
||||
assert.True(t, dbFederationHost.KeyID.Valid)
|
||||
assert.Equal(t, "meow", dbFederationHost.KeyID.String)
|
||||
|
||||
assert.True(t, dbFederationHost.PublicKey.Valid)
|
||||
assert.Equal(t, sql.RawBytes{0x23, 0x42}, dbFederationHost.PublicKey.V)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStoreFederatedUser(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
t.Run("ExplicitNull", func(t *testing.T) {
|
||||
federatedUser := user.FederatedUser{
|
||||
UserID: 0,
|
||||
ExternalID: "ExplicitNull",
|
||||
FederationHostID: 0,
|
||||
KeyID: sql.NullString{Valid: false},
|
||||
PublicKey: sql.Null[sql.RawBytes]{Valid: false},
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(db.DefaultContext).Insert(&federatedUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbFederatedUser := new(user.FederatedUser)
|
||||
has, err := db.GetEngine(db.DefaultContext).Where("user_id=?", 0).Get(dbFederatedUser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, has)
|
||||
|
||||
assert.False(t, dbFederatedUser.KeyID.Valid)
|
||||
assert.False(t, dbFederatedUser.PublicKey.Valid)
|
||||
})
|
||||
|
||||
t.Run("NotNull", func(t *testing.T) {
|
||||
federatedUser := user.FederatedUser{
|
||||
UserID: 1,
|
||||
ExternalID: "ImplicitNull",
|
||||
FederationHostID: 1,
|
||||
KeyID: sql.NullString{Valid: true, String: "woem"},
|
||||
PublicKey: sql.Null[sql.RawBytes]{Valid: true, V: sql.RawBytes{0x42, 0x23}},
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(db.DefaultContext).Insert(&federatedUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbFederatedUser := new(user.FederatedUser)
|
||||
has, err := db.GetEngine(db.DefaultContext).Where("user_id=?", 1).Get(dbFederatedUser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, has)
|
||||
|
||||
assert.True(t, dbFederatedUser.KeyID.Valid)
|
||||
assert.Equal(t, "woem", dbFederatedUser.KeyID.String)
|
||||
assert.True(t, dbFederatedUser.PublicKey.Valid)
|
||||
assert.Equal(t, sql.RawBytes{0x42, 0x23}, dbFederatedUser.PublicKey.V)
|
||||
})
|
||||
}
|
Loading…
Add table
Reference in a new issue