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:
famfo 2025-04-03 15:24:15 +00:00 committed by Gusted
parent ba5b157f7e
commit 77b0275572
22 changed files with 681 additions and 122 deletions

View file

@ -4,6 +4,7 @@
package forgefed package forgefed
import ( import (
"database/sql"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -15,12 +16,14 @@ import (
// FederationHost data type // FederationHost data type
// swagger:model // swagger:model
type FederationHost struct { type FederationHost struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"` HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
NodeInfo NodeInfo `xorm:"extends NOT NULL"` NodeInfo NodeInfo `xorm:"extends NOT NULL"`
LatestActivity time.Time `xorm:"NOT NULL"` LatestActivity time.Time `xorm:"NOT NULL"`
Created timeutil.TimeStamp `xorm:"created"` Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"` 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. // Factory function for FederationHost. Created struct is asserted to be valid.

View file

@ -30,9 +30,9 @@ func GetFederationHost(ctx context.Context, ID int64) (*FederationHost, error) {
return host, nil 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) 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 { if err != nil {
return nil, err return nil, err
} else if !has { } else if !has {
@ -44,6 +44,14 @@ func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost
return host, nil 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 { func CreateFederationHost(ctx context.Context, host *FederationHost) error {
if res, err := validation.IsValid(host); !res { if res, err := validation.IsValid(host); !res {
return err return err

View file

@ -94,6 +94,8 @@ var migrations = []*Migration{
NewMigration("Add `created_unix` column to `user_redirect` table", AddCreatedUnixToRedirect), NewMigration("Add `created_unix` column to `user_redirect` table", AddCreatedUnixToRedirect),
// v27 -> v28 // v27 -> v28
NewMigration("Add pronoun privacy settings to user", AddHidePronounsOptionToUser), 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. // GetCurrentDBVersion returns the current Forgejo database version.

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

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

View file

@ -4,14 +4,20 @@
package user package user
import ( import (
"context"
"database/sql"
"forgejo.org/models/db"
"forgejo.org/modules/validation" "forgejo.org/modules/validation"
) )
type FederatedUser struct { type FederatedUser struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL"` UserID int64 `xorm:"NOT NULL"`
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
FederationHostID int64 `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) { 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 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 { func (user FederatedUser) Validate() []string {
var result []string var result []string
result = append(result, validation.ValidateNotEmpty(user.UserID, "UserID")...) result = append(result, validation.ValidateNotEmpty(user.UserID, "UserID")...)

View file

@ -311,11 +311,6 @@ func (u *User) HTMLURL() string {
return setting.AppURL + url.PathEscape(u.Name) 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. // OrganisationLink returns the organization sub page link.
func (u *User) OrganisationLink() string { func (u *User) OrganisationLink() string {
return setting.AppSubURL + "/org/" + url.PathEscape(u.Name) return setting.AppSubURL + "/org/" + url.PathEscape(u.Name)

View file

@ -73,30 +73,30 @@ func (u *User) IsActions() bool {
} }
const ( const (
APActorUserID = -3 APServerActorUserID = -3
APActorUserName = "actor" APServerActorUserName = "actor"
APActorEmail = "noreply@forgejo.org" APServerActorEmail = "noreply@forgejo.org"
) )
func NewAPActorUser() *User { func NewAPServerActor() *User {
return &User{ return &User{
ID: APActorUserID, ID: APServerActorUserID,
Name: APActorUserName, Name: APServerActorUserName,
LowerName: APActorUserName, LowerName: APServerActorUserName,
IsActive: true, IsActive: true,
Email: APActorEmail, Email: APServerActorEmail,
KeepEmailPrivate: true, KeepEmailPrivate: true,
LoginName: APActorUserName, LoginName: APServerActorUserName,
Type: UserTypeIndividual, Type: UserTypeIndividual,
Visibility: structs.VisibleTypePublic, Visibility: structs.VisibleTypePublic,
} }
} }
func APActorUserAPActorID() string { func APServerActorID() string {
path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor") path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor")
return path return path
} }
func (u *User) IsAPActor() bool { func (u *User) IsAPServerActor() bool {
return u != nil && u.ID == APActorUserID return u != nil && u.ID == APServerActorUserID
} }

View file

@ -139,9 +139,21 @@ func TestAPActorID(t *testing.T) {
user := user_model.User{ID: 1} user := user_model.User{ID: 1}
url := user.APActorID() url := user.APActorID()
expected := "https://try.gitea.io/api/v1/activitypub/user-id/1" expected := "https://try.gitea.io/api/v1/activitypub/user-id/1"
if url != expected { assert.Equal(t, expected, url)
t.Errorf("unexpected APActorID, expected: %q, actual: %q", 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) { func TestSearchUsers(t *testing.T) {

View file

@ -191,10 +191,17 @@ func (c *Client) GetBody(uri string) ([]byte, error) {
return nil, err return nil, err
} }
defer response.Body.Close() 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 { if err != nil {
return nil, err return nil, err
} }
log.Debug("Client: got body: %v", charLimiter(string(body), 120)) log.Debug("Client: got body: %v", charLimiter(string(body), 120))
return body, nil return body, nil
} }

View file

@ -15,18 +15,20 @@ var (
Enabled bool Enabled bool
ShareUserStatistics bool ShareUserStatistics bool
MaxSize int64 MaxSize int64
Algorithms []string SignatureAlgorithms []string
DigestAlgorithm string DigestAlgorithm string
GetHeaders []string GetHeaders []string
PostHeaders []string PostHeaders []string
SignatureEnforced bool
}{ }{
Enabled: false, Enabled: false,
ShareUserStatistics: true, ShareUserStatistics: true,
MaxSize: 4, MaxSize: 4,
Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"}, SignatureAlgorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
DigestAlgorithm: "SHA-256", DigestAlgorithm: "SHA-256",
GetHeaders: []string{"(request-target)", "Date", "Host"}, GetHeaders: []string{"(request-target)", "Date", "Host"},
PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"}, PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"},
SignatureEnforced: true,
} }
) )
@ -44,8 +46,8 @@ func loadFederationFrom(rootCfg ConfigProvider) {
// Get MaxSize in bytes instead of MiB // Get MaxSize in bytes instead of MiB
Federation.MaxSize = 1 << 20 * Federation.MaxSize Federation.MaxSize = 1 << 20 * Federation.MaxSize
HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.Algorithms)) HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.SignatureAlgorithms))
for i, alg := range Federation.Algorithms { for i, alg := range Federation.SignatureAlgorithms {
HttpsigAlgs[i] = httpsig.Algorithm(alg) HttpsigAlgs[i] = httpsig.Algorithm(alg)
} }
} }

View file

@ -95,7 +95,7 @@ func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server {
}) })
} }
for _, repository := range mock.Repositories { 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) { func(res http.ResponseWriter, req *http.Request) {
if req.Method != "POST" { if req.Method != "POST" {
t.Errorf("POST expected at: %q", req.URL.EscapedPath()) t.Errorf("POST expected at: %q", req.URL.EscapedPath())

View file

@ -28,7 +28,7 @@ func Actor(ctx *context.APIContext) {
// "200": // "200":
// "$ref": "#/responses/ActivityPub" // "$ref": "#/responses/ActivityPub"
link := user_model.APActorUserAPActorID() link := user_model.APServerActorID()
actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType) actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType)
actor.PreferredUsername = ap.NaturalLanguageValuesNew() actor.PreferredUsername = ap.NaturalLanguageValuesNew()
@ -46,7 +46,7 @@ func Actor(ctx *context.APIContext) {
actor.PublicKey.ID = ap.IRI(link + "#main-key") actor.PublicKey.ID = ap.IRI(link + "#main-key")
actor.PublicKey.Owner = ap.IRI(link) 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 { if err != nil {
ctx.ServerError("GetPublicKey", err) ctx.ServerError("GetPublicKey", err)
return return

View file

@ -6,59 +6,141 @@ package activitypub
import ( import (
"crypto" "crypto"
"crypto/x509" "crypto/x509"
"database/sql"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"forgejo.org/models/db"
"forgejo.org/models/forgefed"
"forgejo.org/models/user"
"forgejo.org/modules/activitypub" "forgejo.org/modules/activitypub"
"forgejo.org/modules/httplib" fm "forgejo.org/modules/forgefed"
"forgejo.org/modules/log" "forgejo.org/modules/log"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
gitea_context "forgejo.org/services/context" gitea_context "forgejo.org/services/context"
"forgejo.org/services/federation"
"github.com/42wim/httpsig" "github.com/42wim/httpsig"
ap "github.com/go-ap/activitypub" ap "github.com/go-ap/activitypub"
) )
func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) { func decodePublicKeyPem(pubKeyPem string) ([]byte, 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
block, _ := pem.Decode([]byte(pubKeyPem)) block, _ := pem.Decode([]byte(pubKeyPem))
if block == nil || block.Type != "PUBLIC KEY" { if block == nil || block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type") 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) { func getFederatedUser(ctx *gitea_context.APIContext, person *ap.Person, federationHost *forgefed.FederationHost) (*user.FederatedUser, error) {
req := httplib.NewRequest(iri.String(), http.MethodGet) dbUser, err := user.GetUserByFederatedURI(ctx, person.ID.String())
req.Header("Accept", activitypub.ActivityStreamsContentType)
req.Header("User-Agent", "Gitea/"+setting.AppVer)
resp, err := req.Response()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if dbUser != nil {
return nil, fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status) 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) { func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) {
if !setting.Federation.SignatureEnforced {
return true, nil
}
r := ctx.Req r := ctx.Req
// 1. Figure out what key we need to verify // 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 { if err != nil {
return false, err return false, err
} }
ID := v.KeyId() ID := v.KeyId()
idIRI, err := url.Parse(ID) idIRI, err := url.Parse(ID)
if err != nil { if err != nil {
return false, err return false, err
} }
signatureAlgorithm := httpsig.Algorithm(setting.Federation.SignatureAlgorithms[0])
// 2. Fetch the public key of the other actor // 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 { if err != nil {
return false, err 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 { if err != nil {
return false, err return false, err
} }
// 3. Verify the other actor's key
algo := httpsig.Algorithm(setting.Federation.Algorithms[0]) if federationHost != nil && federationHost.PublicKey.Valid {
authenticated = v.Verify(pubKey, algo) == nil 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 return authenticated, err
} }

View file

@ -840,22 +840,22 @@ func Routes() *web.Route {
m.Group("/activitypub", func() { m.Group("/activitypub", func() {
// deprecated, remove in 1.20, use /user-id/{user-id} instead // deprecated, remove in 1.20, use /user-id/{user-id} instead
m.Group("/user/{username}", func() { m.Group("/user/{username}", func() {
m.Get("", activitypub.Person) m.Get("", activitypub.ReqHTTPSignature(), activitypub.Person)
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
}, context.UserAssignmentAPI(), checkTokenPublicOnly()) }, context.UserAssignmentAPI(), checkTokenPublicOnly())
m.Group("/user-id/{user-id}", func() { m.Group("/user-id/{user-id}", func() {
m.Get("", activitypub.Person) m.Get("", activitypub.ReqHTTPSignature(), activitypub.Person)
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
}, context.UserIDAssignmentAPI(), checkTokenPublicOnly()) }, context.UserIDAssignmentAPI(), checkTokenPublicOnly())
m.Group("/actor", func() { m.Group("/actor", func() {
m.Get("", activitypub.Actor) m.Get("", activitypub.Actor)
m.Post("/inbox", activitypub.ActorInbox) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.ActorInbox)
}) })
m.Group("/repository-id/{repository-id}", func() { m.Group("/repository-id/{repository-id}", func() {
m.Get("", activitypub.Repository) m.Get("", activitypub.ReqHTTPSignature(), activitypub.Repository)
m.Post("/inbox", m.Post("/inbox",
bind(forgefed.ForgeLike{}), bind(forgefed.ForgeLike{}),
// TODO: activitypub.ReqHTTPSignature(), activitypub.ReqHTTPSignature(),
activitypub.RepositoryInbox) activitypub.RepositoryInbox)
}, context.RepositoryIDAssignmentAPI()) }, context.RepositoryIDAssignmentAPI())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub))

View file

@ -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. // 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 return roleDescriptor, nil
} }

View file

@ -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) { func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) {
actionsUser := user.NewActionsUser() actionsUser := user.NewAPServerActor()
clientFactory, err := activitypub.GetClientFactory(ctx) clientFactory, err := activitypub.GetClientFactory(ctx)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
body, err := client.GetBody(actorID.AsWellKnownNodeInfoURI()) body, err := client.GetBody(actorID.AsWellKnownNodeInfoURI())
if err != nil { if err != nil {
return nil, err return nil, err
} }
nodeInfoWellKnown, err := forgefed.NewNodeInfoWellKnown(body) nodeInfoWellKnown, err := forgefed.NewNodeInfoWellKnown(body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
body, err = client.GetBody(nodeInfoWellKnown.Href) body, err = client.GetBody(nodeInfoWellKnown.Href)
if err != nil { if err != nil {
return nil, err return nil, err
} }
nodeInfo, err := forgefed.NewNodeInfo(body) nodeInfo, err := forgefed.NewNodeInfo(body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result, err := forgefed.NewFederationHost(nodeInfo, actorID.Host) result, err := forgefed.NewFederationHost(nodeInfo, actorID.Host)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = forgefed.CreateFederationHost(ctx, &result) err = forgefed.CreateFederationHost(ctx, &result)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &result, nil 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) { 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.NewAPServerActor()
actionsUser := user.NewActionsUser()
clientFactory, err := activitypub.GetClientFactory(ctx) clientFactory, err := activitypub.GetClientFactory(ctx)
if err != nil { if err != nil {
return nil, nil, err 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
body, err := client.GetBody(personID.AsURI()) body, err := apClient.GetBody(personID.AsURI())
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -176,26 +184,32 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if res, err := validation.IsValid(person); !res { if res, err := validation.IsValid(person); !res {
return nil, nil, err return nil, nil, err
} }
log.Info("Fetched valid person:%q", person) log.Info("Fetched valid person:%q", person)
localFqdn, err := url.ParseRequestURI(setting.AppURL) localFqdn, err := url.ParseRequestURI(setting.AppURL)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
email := fmt.Sprintf("f%v@%v", uuid.New().String(), localFqdn.Hostname()) email := fmt.Sprintf("f%v@%v", uuid.New().String(), localFqdn.Hostname())
loginName := personID.AsLoginName() loginName := personID.AsLoginName()
name := fmt.Sprintf("%v%v", person.PreferredUsername.String(), personID.HostSuffix()) name := fmt.Sprintf("%v%v", person.PreferredUsername.String(), personID.HostSuffix())
fullName := person.Name.String() fullName := person.Name.String()
if len(person.Name) == 0 { if len(person.Name) == 0 {
fullName = name fullName = name
} }
password, err := password.Generate(32) password, err := password.Generate(32)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
newUser := user.User{ newUser := user.User{
LowerName: strings.ToLower(name), LowerName: strings.ToLower(name),
Name: name, Name: name,
@ -209,16 +223,18 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
IsAdmin: false, IsAdmin: false,
NormalizedFederatedURI: personID.AsURI(), NormalizedFederatedURI: personID.AsURI(),
} }
federatedUser := user.FederatedUser{ federatedUser := user.FederatedUser{
ExternalID: personID.ID, ExternalID: personID.ID,
FederationHostID: federationHostID, FederationHostID: federationHostID,
} }
err = user.CreateFederatedUser(ctx, &newUser, &federatedUser) err = user.CreateFederatedUser(ctx, &newUser, &federatedUser)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
log.Info("Created federatedUser:%q", federatedUser)
log.Info("Created federatedUser:%q", federatedUser)
return &newUser, &federatedUser, nil return &newUser, &federatedUser, nil
} }
@ -274,7 +290,8 @@ func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error
if err != nil { if err != nil {
return err return err
} }
apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorID())
apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorKeyID())
if err != nil { if err != nil {
return err return err
} }
@ -285,7 +302,7 @@ func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error
return err 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 { if err != nil {
log.Error("error %v while sending activity: %q", err, activity) log.Error("error %v while sending activity: %q", err, activity)
} }

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

View file

@ -26,33 +26,47 @@ import (
func TestActivityPubPerson(t *testing.T) { func TestActivityPubPerson(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)() defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 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 user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
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")
var person ap.Person clientFactory, err := activitypub.GetClientFactory(db.DefaultContext)
err := person.UnmarshalJSON(resp.Body.Bytes()) require.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, ap.PersonType, person.Type) apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID())
assert.Equal(t, username, person.PreferredUsername.String()) require.NoError(t, err)
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())
pubKey := person.PublicKey // Unsigned request
assert.NotNil(t, pubKey) t.Run("UnsignedRequest", func(t *testing.T) {
publicKeyID := keyID + "#main-key" req := NewRequest(t, "GET", userURL)
assert.Equal(t, pubKey.ID.String(), publicKeyID) MakeRequest(t, req, http.StatusBadRequest)
})
pubKeyPem := pubKey.PublicKeyPem t.Run("SignedRequestValidation", func(t *testing.T) {
assert.NotNil(t, pubKeyPem) // Signed requset
assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) 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) { func TestActivityPubMissingPerson(t *testing.T) {

View file

@ -28,18 +28,28 @@ import (
func TestActivityPubRepository(t *testing.T) { func TestActivityPubRepository(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)() defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
defer tests.PrepareTestEnv(t)()
repositoryID := 2 onGiteaRun(t, func(t *testing.T, u *url.URL) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) repositoryID := 2
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "@context")
var repository forgefed_modules.Repository apServerActor := user.NewAPServerActor()
err := repository.UnmarshalJSON(resp.Body.Bytes())
require.NoError(t, err)
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) { func TestActivityPubMissingRepository(t *testing.T) {
@ -48,7 +58,7 @@ func TestActivityPubMissingRepository(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
repositoryID := 9999999 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) resp := MakeRequest(t, req, http.StatusNotFound)
assert.Contains(t, resp.Body.String(), "repository does not exist") assert.Contains(t, resp.Body.String(), "repository does not exist")
} }
@ -62,14 +72,16 @@ func TestActivityPubRepositoryInboxValid(t *testing.T) {
defer federatedSrv.Close() defer federatedSrv.Close()
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
actionsUser := user.NewActionsUser() apServerActor := user.NewAPServerActor()
repositoryID := 2 repositoryID := 2
timeNow := time.Now().UTC() timeNow := time.Now().UTC()
cf, err := activitypub.GetClientFactory(db.DefaultContext) cf, err := activitypub.GetClientFactory(db.DefaultContext)
require.NoError(t, err) 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) require.NoError(t, err)
repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String()
activity1 := []byte(fmt.Sprintf( activity1 := []byte(fmt.Sprintf(
@ -139,14 +151,16 @@ func TestActivityPubRepositoryInboxInvalid(t *testing.T) {
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
actionsUser := user.NewActionsUser() apServerActor := user.NewAPServerActor()
repositoryID := 2 repositoryID := 2
cf, err := activitypub.GetClientFactory(db.DefaultContext) cf, err := activitypub.GetClientFactory(db.DefaultContext)
require.NoError(t, err) 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) 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"}`) activity := []byte(`{"type":"Wrong"}`)
resp, err := c.Post(activity, repoInboxURL) resp, err := c.Post(activity, repoInboxURL)
require.NoError(t, err) require.NoError(t, err)

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

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