separate public key handler (#64)

This commit is contained in:
Tobi Smethurst 2021-06-26 16:21:40 +02:00 committed by GitHub
parent 82d2544d7d
commit b6c62309f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 162 additions and 18 deletions

View File

@ -0,0 +1,45 @@
package user
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// PublicKeyGETHandler should be served at eg https://example.org/users/:username/main-key.
//
// The goal here is to return a MINIMAL activitypub representation of an account
// in the form of a vocab.ActivityStreamsPerson. The account will only contain the id,
// public key, username, and type of the account.
func (m *Module) PublicKeyGETHandler(c *gin.Context) {
l := m.log.WithFields(logrus.Fields{
"func": "PublicKeyGETHandler",
"url": c.Request.RequestURI,
})
requestedUsername := c.Param(UsernameKey)
if requestedUsername == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"})
return
}
// make sure this actually an AP request
format := c.NegotiateFormat(ActivityPubAcceptHeaders...)
if format == "" {
c.JSON(http.StatusNotAcceptable, gin.H{"error": "could not negotiate format with given Accept header(s)"})
return
}
l.Tracef("negotiated format: %s", format)
// make a copy of the context to pass along so we don't break anything
cp := c.Copy()
user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetFediUser handles auth as well
if err != nil {
l.Info(err.Error())
c.JSON(err.Code(), gin.H{"error": err.Safe()})
return
}
c.JSON(http.StatusOK, user)
}

View File

@ -40,6 +40,8 @@ const (
// Use this anywhere you need to know the username of the user being queried. // Use this anywhere you need to know the username of the user being queried.
// Eg https://example.org/users/:username // Eg https://example.org/users/:username
UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey
// UsersPublicKeyPath is a path to a user's public key, for serving bare minimum AP representations.
UsersPublicKeyPath = UsersBasePathWithUsername + "/" + util.PublicKeyPath
// UsersInboxPath is for serving POST requests to a user's inbox with the given username key. // UsersInboxPath is for serving POST requests to a user's inbox with the given username key.
UsersInboxPath = UsersBasePathWithUsername + "/" + util.InboxPath UsersInboxPath = UsersBasePathWithUsername + "/" + util.InboxPath
// UsersFollowersPath is for serving GET request's to a user's followers list, with the given username key. // UsersFollowersPath is for serving GET request's to a user's followers list, with the given username key.
@ -80,5 +82,6 @@ func (m *Module) Route(s router.Router) error {
s.AttachHandler(http.MethodGet, UsersFollowersPath, m.FollowersGETHandler) s.AttachHandler(http.MethodGet, UsersFollowersPath, m.FollowersGETHandler)
s.AttachHandler(http.MethodGet, UsersFollowingPath, m.FollowingGETHandler) s.AttachHandler(http.MethodGet, UsersFollowingPath, m.FollowingGETHandler)
s.AttachHandler(http.MethodGet, UsersStatusPath, m.StatusGETHandler) s.AttachHandler(http.MethodGet, UsersStatusPath, m.StatusGETHandler)
s.AttachHandler(http.MethodGet, UsersPublicKeyPath, m.PublicKeyGETHandler)
return nil return nil
} }

View File

@ -76,7 +76,7 @@ type Account struct {
*/ */
// Does this account need an approval for new followers? // Does this account need an approval for new followers?
Locked bool `pg:",default:false"` Locked bool `pg:",default:true"`
// Should this account be shown in the instance's profile directory? // Should this account be shown in the instance's profile directory?
Discoverable bool `pg:",default:false"` Discoverable bool `pg:",default:false"`
// Default post privacy for this account // Default post privacy for this account

View File

@ -25,6 +25,8 @@ import (
"net/url" "net/url"
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/sirupsen/logrus"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
@ -96,30 +98,49 @@ func (p *processor) authenticateAndDereferenceFediRequest(username string, r *ht
} }
func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) { func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) {
l := p.log.WithFields(logrus.Fields{
"func": "GetFediUser",
"requestedUsername": requestedUsername,
"requestURL": request.URL.String(),
})
// get the account the request is referring to // get the account the request is referring to
requestedAccount := &gtsmodel.Account{} requestedAccount := &gtsmodel.Account{}
if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
} }
// authenticate the request var requestedPerson vocab.ActivityStreamsPerson
requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) var err error
if err != nil { if util.IsPublicKeyPath(request.URL) {
return nil, gtserror.NewErrorNotAuthorized(err) l.Debug("serving from public key path")
} // if it's a public key path, we don't need to authenticate but we'll only serve the bare minimum user profile needed for the public key
requestedPerson, err = p.tc.AccountToASMinimal(requestedAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
} else if util.IsUserPath(request.URL) {
l.Debug("serving from user path")
// if it's a user path, we want to fully authenticate the request before we serve any data, and then we can serve a more complete profile
requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID)
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
} }
if blocked { if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
} }
requestedPerson, err = p.tc.AccountToAS(requestedAccount)
requestedPerson, err := p.tc.AccountToAS(requestedAccount) if err != nil {
if err != nil { return nil, gtserror.NewErrorInternalError(err)
return nil, gtserror.NewErrorInternalError(err) }
} else {
return nil, gtserror.NewErrorBadRequest(fmt.Errorf("path was not public key path or user path"))
} }
data, err := streams.Serialize(requestedPerson) data, err := streams.Serialize(requestedPerson)

View File

@ -122,6 +122,7 @@ type TypeConverter interface {
// AccountToAS converts a gts model account into an activity streams person, suitable for federation // AccountToAS converts a gts model account into an activity streams person, suitable for federation
AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error)
AccountToASMinimal(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error)
// StatusToAS converts a gts model status into an activity streams note, suitable for federation // StatusToAS converts a gts model status into an activity streams note, suitable for federation
StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error)
// FollowToASFollow converts a gts model Follow into an activity streams Follow, suitable for federation // FollowToASFollow converts a gts model Follow into an activity streams Follow, suitable for federation

View File

@ -258,6 +258,72 @@ func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerso
return person, nil return person, nil
} }
// Converts a gts model account into a VERY MINIMAL Activity Streams person type, following
// the spec laid out for mastodon here: https://docs.joinmastodon.org/spec/activitypub/
//
// The returned account will just have the Type, Username, PublicKey, and ID properties set.
func (c *converter) AccountToASMinimal(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) {
person := streams.NewActivityStreamsPerson()
// id should be the activitypub URI of this user
// something like https://example.org/users/example_user
profileIDURI, err := url.Parse(a.URI)
if err != nil {
return nil, err
}
idProp := streams.NewJSONLDIdProperty()
idProp.SetIRI(profileIDURI)
person.SetJSONLDId(idProp)
// preferredUsername
// Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI.
preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProp.SetXMLSchemaString(a.Username)
person.SetActivityStreamsPreferredUsername(preferredUsernameProp)
// publicKey
// Required for signatures.
publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
// create the public key
publicKey := streams.NewW3IDSecurityV1PublicKey()
// set ID for the public key
publicKeyIDProp := streams.NewJSONLDIdProperty()
publicKeyURI, err := url.Parse(a.PublicKeyURI)
if err != nil {
return nil, err
}
publicKeyIDProp.SetIRI(publicKeyURI)
publicKey.SetJSONLDId(publicKeyIDProp)
// set owner for the public key
publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty()
publicKeyOwnerProp.SetIRI(profileIDURI)
publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp)
// set the pem key itself
encodedPublicKey, err := x509.MarshalPKIXPublicKey(a.PublicKey)
if err != nil {
return nil, err
}
publicKeyBytes := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: encodedPublicKey,
})
publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
publicKeyPEMProp.Set(string(publicKeyBytes))
publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp)
// append the public key to the public key property
publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey)
// set the public key property on the Person
person.SetW3IDSecurityV1PublicKey(publicKeyProp)
return person, nil
}
func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) { func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) {
// ensure prerequisites here before we get stuck in // ensure prerequisites here before we get stuck in

View File

@ -60,6 +60,9 @@ var (
// userPathRegex parses a path that validates and captures the username part from eg /users/example_username // userPathRegex parses a path that validates and captures the username part from eg /users/example_username
userPathRegex = regexp.MustCompile(userPathRegexString) userPathRegex = regexp.MustCompile(userPathRegexString)
userPublicKeyPathRegexString = fmt.Sprintf(`^?/%s/(%s)/%s`, UsersPath, usernameRegexString, PublicKeyPath)
userPublicKeyPathRegex = regexp.MustCompile(userPublicKeyPathRegexString)
inboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, InboxPath) inboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, InboxPath)
// inboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/inbox // inboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/inbox
inboxPathRegex = regexp.MustCompile(inboxPathRegexString) inboxPathRegex = regexp.MustCompile(inboxPathRegexString)

View File

@ -140,7 +140,7 @@ func GenerateURIsForAccount(username string, protocol string, host string) *User
followingURI := fmt.Sprintf("%s/%s", userURI, FollowingPath) followingURI := fmt.Sprintf("%s/%s", userURI, FollowingPath)
likedURI := fmt.Sprintf("%s/%s", userURI, LikedPath) likedURI := fmt.Sprintf("%s/%s", userURI, LikedPath)
collectionURI := fmt.Sprintf("%s/%s/%s", userURI, CollectionsPath, FeaturedPath) collectionURI := fmt.Sprintf("%s/%s/%s", userURI, CollectionsPath, FeaturedPath)
publicKeyURI := fmt.Sprintf("%s#%s", userURI, PublicKeyPath) publicKeyURI := fmt.Sprintf("%s/%s", userURI, PublicKeyPath)
return &UserURIs{ return &UserURIs{
HostURL: hostURL, HostURL: hostURL,
@ -209,6 +209,11 @@ func IsStatusesPath(id *url.URL) bool {
return statusesPathRegex.MatchString(id.Path) return statusesPathRegex.MatchString(id.Path)
} }
// IsPublicKeyPath returns true if the given URL path corresponds to eg /users/example_username/main-key
func IsPublicKeyPath(id *url.URL) bool {
return userPublicKeyPathRegex.MatchString(id.Path)
}
// ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS // ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS
func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) { func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) {
matches := statusesPathRegex.FindStringSubmatch(id.Path) matches := statusesPathRegex.FindStringSubmatch(id.Path)