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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue