From a082cf3a7747eb2aa677d7643a11fc01c41bf7f0 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Fri, 22 Apr 2022 17:23:14 -0700 Subject: [PATCH] Fediverse-based authentication (#1846) * Able to authenticate user against IndieAuth. For #1273 * WIP server indieauth endpoint. For https://github.com/owncast/owncast/issues/1272 * Add migration to remove access tokens from user * Add authenticated bool to user for display purposes * Add indieauth modal and auth flair to display names. For #1273 * Validate URLs and display errors * Renames, cleanups * Handle relative auth endpoint paths. Add error handling for missing redirects. * Disallow using display names in use by registered users. Closes #1810 * Verify code verifier via code challenge on callback * Use relative path to authorization_endpoint * Post-rebase fixes * Use a timestamp instead of a bool for authenticated * Propertly handle and display error in modal * Use auth'ed timestamp to derive authenticated flag to display in chat * Fediverse chat auth via OTP * Increase validity time just in case * Add fediverse auth into auth modal * Text, validation, cleanup updates for fedi auth * Fix typo * Remove unused images * Remove unused file * Add chat display name to auth modal text --- activitypub/activitypub.go | 5 + activitypub/apmodels/activity.go | 99 +++++++-- activitypub/apmodels/webfinger.go | 20 ++ activitypub/outbox/outbox.go | 56 ++++- activitypub/requests/http.go | 22 ++ activitypub/webfinger/webfinger.go | 46 +++++ auth/auth.go | 2 +- auth/fediverse/fediverse.go | 63 ++++++ auth/fediverse/fediverse_test.go | 43 ++++ auth/persistence.go | 1 + controllers/auth/fediverse/fediverse.go | 98 +++++++++ controllers/remoteFollow.go | 39 +--- router/router.go | 6 +- webroot/img/indieauth.png | Bin 6668 -> 10089 bytes webroot/js/app.js | 6 +- webroot/js/chat/indieauth.js | 3 - webroot/js/components/auth-fediverse.js | 206 +++++++++++++++++++ webroot/js/components/auth-indieauth.js | 19 +- webroot/js/components/auth-modal.js | 162 +++++++++++++++ webroot/js/components/chat-settings-modal.js | 39 +++- webroot/styles/app.css | 1 + 21 files changed, 855 insertions(+), 81 deletions(-) create mode 100644 activitypub/webfinger/webfinger.go create mode 100644 auth/fediverse/fediverse.go create mode 100644 auth/fediverse/fediverse_test.go create mode 100644 controllers/auth/fediverse/fediverse.go delete mode 100644 webroot/js/chat/indieauth.js create mode 100644 webroot/js/components/auth-fediverse.js create mode 100644 webroot/js/components/auth-modal.js diff --git a/activitypub/activitypub.go b/activitypub/activitypub.go index ac41e2a90..e8be9f961 100644 --- a/activitypub/activitypub.go +++ b/activitypub/activitypub.go @@ -40,6 +40,11 @@ func SendPublicFederatedMessage(message string) error { return outbox.SendPublicMessage(message) } +// SendDirectFederatedMessage will send a direct message to a single account. +func SendDirectFederatedMessage(message, account string) error { + return outbox.SendDirectMessageToAccount(message, account) +} + // GetFollowerCount will return the local tracked follower count. func GetFollowerCount() (int64, error) { return persistence.GetFollowerCount() diff --git a/activitypub/apmodels/activity.go b/activitypub/apmodels/activity.go index 730419610..35cd6e56c 100644 --- a/activitypub/apmodels/activity.go +++ b/activitypub/apmodels/activity.go @@ -17,13 +17,76 @@ const ( PUBLIC PrivacyAudience = "https://www.w3.org/ns/activitystreams#Public" ) -// MakeCreateActivity will return a new Create activity with the provided ID. -func MakeCreateActivity(activityID *url.URL) vocab.ActivityStreamsCreate { - activity := streams.NewActivityStreamsCreate() - id := streams.NewJSONLDIdProperty() - id.Set(activityID) - activity.SetJSONLDId(id) +// MakeNotePublic ses the required proeprties to make this note seen as public. +func MakeNotePublic(note vocab.ActivityStreamsNote) vocab.ActivityStreamsNote { + public, _ := url.Parse(PUBLIC) + to := streams.NewActivityStreamsToProperty() + to.AppendIRI(public) + note.SetActivityStreamsTo(to) + audience := streams.NewActivityStreamsAudienceProperty() + audience.AppendIRI(public) + note.SetActivityStreamsAudience(audience) + + return note +} + +// MakeNoteDirect sets the required properties to make this note seen as a +// direct message. +func MakeNoteDirect(note vocab.ActivityStreamsNote, toIRI *url.URL) vocab.ActivityStreamsNote { + to := streams.NewActivityStreamsCcProperty() + to.AppendIRI(toIRI) + to.AppendIRI(toIRI) + note.SetActivityStreamsCc(to) + + // Mastodon requires a tag with a type of "mention" and href of the account + // for a message to be a "Direct Message". + tagProperty := streams.NewActivityStreamsTagProperty() + tag := streams.NewTootHashtag() + tagTypeProperty := streams.NewJSONLDTypeProperty() + tagTypeProperty.AppendXMLSchemaString("Mention") + tag.SetJSONLDType(tagTypeProperty) + + tagHrefProperty := streams.NewActivityStreamsHrefProperty() + tagHrefProperty.Set(toIRI) + tag.SetActivityStreamsHref(tagHrefProperty) + tagProperty.AppendTootHashtag(tag) + tagProperty.AppendTootHashtag(tag) + note.SetActivityStreamsTag(tagProperty) + + return note +} + +// MakeActivityDirect sets the required properties to make this activity seen +// as a direct message. +func MakeActivityDirect(activity vocab.ActivityStreamsCreate, toIRI *url.URL) vocab.ActivityStreamsCreate { + to := streams.NewActivityStreamsCcProperty() + to.AppendIRI(toIRI) + to.AppendIRI(toIRI) + activity.SetActivityStreamsCc(to) + + // Mastodon requires a tag with a type of "mention" and href of the account + // for a message to be a "Direct Message". + tagProperty := streams.NewActivityStreamsTagProperty() + tag := streams.NewTootHashtag() + tagTypeProperty := streams.NewJSONLDTypeProperty() + tagTypeProperty.AppendXMLSchemaString("Mention") + tag.SetJSONLDType(tagTypeProperty) + + tagHrefProperty := streams.NewActivityStreamsHrefProperty() + tagHrefProperty.Set(toIRI) + tag.SetActivityStreamsHref(tagHrefProperty) + tagProperty.AppendTootHashtag(tag) + tagProperty.AppendTootHashtag(tag) + + activity.SetActivityStreamsTag(tagProperty) + + return activity +} + +// MakeActivityPublic sets the required properties to make this activity +// seen as public. +func MakeActivityPublic(activity vocab.ActivityStreamsCreate) vocab.ActivityStreamsCreate { // TO the public if we're not treating ActivityPub as "private". if !data.GetFederationIsPrivate() { public, _ := url.Parse(PUBLIC) @@ -40,6 +103,16 @@ func MakeCreateActivity(activityID *url.URL) vocab.ActivityStreamsCreate { return activity } +// MakeCreateActivity will return a new Create activity with the provided ID. +func MakeCreateActivity(activityID *url.URL) vocab.ActivityStreamsCreate { + activity := streams.NewActivityStreamsCreate() + id := streams.NewJSONLDIdProperty() + id.Set(activityID) + activity.SetJSONLDId(id) + + return activity +} + // MakeUpdateActivity will return a new Update activity with the provided aID. func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate { activity := streams.NewActivityStreamsUpdate() @@ -61,9 +134,11 @@ func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate { // MakeNote will return a new Note object. func MakeNote(text string, noteIRI *url.URL, attributedToIRI *url.URL) vocab.ActivityStreamsNote { note := streams.NewActivityStreamsNote() + content := streams.NewActivityStreamsContentProperty() content.AppendXMLSchemaString(text) note.SetActivityStreamsContent(content) + id := streams.NewJSONLDIdProperty() id.Set(noteIRI) note.SetJSONLDId(id) @@ -77,17 +152,5 @@ func MakeNote(text string, noteIRI *url.URL, attributedToIRI *url.URL) vocab.Act attr.AppendIRI(attributedTo) note.SetActivityStreamsAttributedTo(attr) - // To the public if we're not treating ActivityPub as "private". - if !data.GetFederationIsPrivate() { - public, _ := url.Parse(PUBLIC) - to := streams.NewActivityStreamsToProperty() - to.AppendIRI(public) - note.SetActivityStreamsTo(to) - - audience := streams.NewActivityStreamsAudienceProperty() - audience.AppendIRI(public) - note.SetActivityStreamsAudience(audience) - } - return note } diff --git a/activitypub/apmodels/webfinger.go b/activitypub/apmodels/webfinger.go index c24199dbb..316cbe894 100644 --- a/activitypub/apmodels/webfinger.go +++ b/activitypub/apmodels/webfinger.go @@ -11,6 +11,11 @@ type WebfingerResponse struct { Links []Link `json:"links"` } +// WebfingerProfileRequestResponse represents a Webfinger profile request response. +type WebfingerProfileRequestResponse struct { + Self string +} + // Link represents a Webfinger response Link entity. type Link struct { Rel string `json:"rel"` @@ -41,3 +46,18 @@ func MakeWebfingerResponse(account string, inbox string, host string) WebfingerR }, } } + +// MakeWebFingerRequestResponseFromData converts WebFinger data to an easier +// to use model. +func MakeWebFingerRequestResponseFromData(data []map[string]interface{}) WebfingerProfileRequestResponse { + response := WebfingerProfileRequestResponse{} + for _, link := range data { + if link["rel"] == "self" { + return WebfingerProfileRequestResponse{ + Self: link["href"].(string), + } + } + } + + return response +} diff --git a/activitypub/outbox/outbox.go b/activitypub/outbox/outbox.go index 5830912a1..ed2486e57 100644 --- a/activitypub/outbox/outbox.go +++ b/activitypub/outbox/outbox.go @@ -1,7 +1,6 @@ package outbox import ( - "errors" "fmt" "net/url" "path/filepath" @@ -13,7 +12,11 @@ import ( "github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/persistence" + "github.com/owncast/owncast/activitypub/requests" + "github.com/owncast/owncast/activitypub/resolvers" + "github.com/owncast/owncast/activitypub/webfinger" "github.com/owncast/owncast/activitypub/workerpool" + "github.com/pkg/errors" "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/data" @@ -61,6 +64,12 @@ func SendLive() error { activity, _, note, noteID := createBaseOutboundMessage(textContent) + // To the public if we're not treating ActivityPub as "private". + if !data.GetFederationIsPrivate() { + note = apmodels.MakeNotePublic(note) + activity = apmodels.MakeActivityPublic(activity) + } + note.SetActivityStreamsTag(tagProp) // Attach an image along with the Federated message. @@ -106,6 +115,37 @@ func SendLive() error { return nil } +// SendDirectMessageToAccount will send a direct message to a single account. +func SendDirectMessageToAccount(textContent, account string) error { + links, err := webfinger.GetWebfingerLinks(account) + if err != nil { + return errors.Wrap(err, "unable to get webfinger links when sending private message") + } + user := apmodels.MakeWebFingerRequestResponseFromData(links) + + iri := user.Self + actor, err := resolvers.GetResolvedActorFromIRI(iri) + if err != nil { + return errors.Wrap(err, "unable to resolve actor to send message to") + } + + activity, _, note, _ := createBaseOutboundMessage(textContent) + + // Set direct message visibility + activity = apmodels.MakeActivityDirect(activity, actor.ActorIri) + note = apmodels.MakeNoteDirect(note, actor.ActorIri) + object := activity.GetActivityStreamsObject() + object.SetActivityStreamsNote(0, note) + + b, err := apmodels.Serialize(activity) + if err != nil { + log.Errorln("unable to serialize custom fediverse message activity", err) + return errors.Wrap(err, "unable to serialize custom fediverse message activity") + } + + return SendToUser(actor.Inbox, b) +} + // SendPublicMessage will send a public message to all followers. func SendPublicMessage(textContent string) error { originalContent := textContent @@ -191,6 +231,20 @@ func SendToFollowers(payload []byte) error { return nil } +// SendToUser will send a payload to a single specific inbox. +func SendToUser(inbox *url.URL, payload []byte) error { + localActor := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername()) + + req, err := requests.CreateSignedRequest(payload, inbox, localActor) + if err != nil { + return errors.Wrap(err, "unable to create outbox request") + } + + workerpool.AddToOutboundQueue(req) + + return nil +} + // UpdateFollowersWithAccountUpdates will send an update to all followers alerting of a profile update. func UpdateFollowersWithAccountUpdates() error { // Don't do anything if federation is disabled. diff --git a/activitypub/requests/http.go b/activitypub/requests/http.go index 5552488f3..1c879cb7b 100644 --- a/activitypub/requests/http.go +++ b/activitypub/requests/http.go @@ -1,12 +1,16 @@ package requests import ( + "bytes" "encoding/json" + "fmt" "net/http" + "net/url" "github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams/vocab" "github.com/owncast/owncast/activitypub/crypto" + "github.com/owncast/owncast/config" log "github.com/sirupsen/logrus" ) @@ -50,3 +54,21 @@ func WriteResponse(payload []byte, w http.ResponseWriter, publicKey crypto.Publi return nil } + +// CreateSignedRequest will create a signed POST request of a payload to the provided destination. +func CreateSignedRequest(payload []byte, url *url.URL, fromActorIRI *url.URL) (*http.Request, error) { + log.Debugln("Sending", string(payload), "to", url) + + req, _ := http.NewRequest(http.MethodPost, url.String(), bytes.NewBuffer(payload)) + + ua := fmt.Sprintf("%s; https://owncast.online", config.GetReleaseString()) + req.Header.Set("User-Agent", ua) + req.Header.Set("Content-Type", "application/activity+json") + + if err := crypto.SignRequest(req, payload, fromActorIRI); err != nil { + log.Errorln("error signing request:", err) + return nil, err + } + + return req, nil +} diff --git a/activitypub/webfinger/webfinger.go b/activitypub/webfinger/webfinger.go new file mode 100644 index 000000000..4447b9e4a --- /dev/null +++ b/activitypub/webfinger/webfinger.go @@ -0,0 +1,46 @@ +package webfinger + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +// GetWebfingerLinks will return webfinger data for an account. +func GetWebfingerLinks(account string) ([]map[string]interface{}, error) { + type webfingerResponse struct { + Links []map[string]interface{} `json:"links"` + } + + account = strings.TrimLeft(account, "@") // remove any leading @ + accountComponents := strings.Split(account, "@") + fediverseServer := accountComponents[1] + + // HTTPS is required. + requestURL, err := url.Parse("https://" + fediverseServer) + if err != nil { + return nil, fmt.Errorf("unable to parse fediverse server host %s", fediverseServer) + } + + requestURL.Path = "/.well-known/webfinger" + query := requestURL.Query() + query.Add("resource", fmt.Sprintf("acct:%s", account)) + requestURL.RawQuery = query.Encode() + + response, err := http.DefaultClient.Get(requestURL.String()) + if err != nil { + return nil, err + } + + defer response.Body.Close() + + var links webfingerResponse + decoder := json.NewDecoder(response.Body) + if err := decoder.Decode(&links); err != nil { + return nil, err + } + + return links.Links, nil +} diff --git a/auth/auth.go b/auth/auth.go index ce2219452..def148a87 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -4,8 +4,8 @@ package auth type Type string // The different auth types we support. -// Currently only IndieAuth. const ( // IndieAuth https://indieauth.spec.indieweb.org/. IndieAuth Type = "indieauth" + Fediverse Type = "fediverse" ) diff --git a/auth/fediverse/fediverse.go b/auth/fediverse/fediverse.go new file mode 100644 index 000000000..8f00ee120 --- /dev/null +++ b/auth/fediverse/fediverse.go @@ -0,0 +1,63 @@ +package fediverse + +import ( + "crypto/rand" + "io" + "time" +) + +// OTPRegistration represents a single OTP request. +type OTPRegistration struct { + UserID string + UserDisplayName string + Code string + Account string + Timestamp time.Time +} + +// Key by access token to limit one OTP request for a person +// to be active at a time. +var pendingAuthRequests = make(map[string]OTPRegistration) + +// RegisterFediverseOTP will start the OTP flow for a user, creating a new +// code and returning it to be sent to a destination. +func RegisterFediverseOTP(accessToken, userID, userDisplayName, account string) OTPRegistration { + code, _ := createCode() + r := OTPRegistration{ + Code: code, + UserID: userID, + UserDisplayName: userDisplayName, + Account: account, + Timestamp: time.Now(), + } + pendingAuthRequests[accessToken] = r + + return r +} + +// ValidateFediverseOTP will verify a OTP code for a auth request. +func ValidateFediverseOTP(accessToken, code string) (bool, *OTPRegistration) { + request, ok := pendingAuthRequests[accessToken] + + if !ok || request.Code != code || time.Since(request.Timestamp) > time.Minute*10 { + return false, nil + } + + delete(pendingAuthRequests, accessToken) + return true, &request +} + +func createCode() (string, error) { + table := [...]byte{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'} + + digits := 6 + b := make([]byte, digits) + n, err := io.ReadAtLeast(rand.Reader, b, digits) + if n != digits { + return "", err + } + for i := 0; i < len(b); i++ { + b[i] = table[int(b[i])%len(table)] + } + return string(b), nil +} diff --git a/auth/fediverse/fediverse_test.go b/auth/fediverse/fediverse_test.go new file mode 100644 index 000000000..8c1d58f66 --- /dev/null +++ b/auth/fediverse/fediverse_test.go @@ -0,0 +1,43 @@ +package fediverse + +import "testing" + +const ( + accessToken = "fake-access-token" + account = "blah" + userID = "fake-user-id" + userDisplayName = "fake-user-display-name" +) + +func TestOTPFlowValidation(t *testing.T) { + r := RegisterFediverseOTP(accessToken, userID, userDisplayName, account) + + if r.Code == "" { + t.Error("Code is empty") + } + + if r.Account != account { + t.Error("Account is not set correctly") + } + + if r.Timestamp.IsZero() { + t.Error("Timestamp is empty") + } + + valid, registration := ValidateFediverseOTP(accessToken, r.Code) + if !valid { + t.Error("Code is not valid") + } + + if registration.Account != account { + t.Error("Account is not set correctly") + } + + if registration.UserID != userID { + t.Error("UserID is not set correctly") + } + + if registration.UserDisplayName != userDisplayName { + t.Error("UserDisplayName is not set correctly") + } +} diff --git a/auth/persistence.go b/auth/persistence.go index d644d0c25..5ace8576f 100644 --- a/auth/persistence.go +++ b/auth/persistence.go @@ -55,6 +55,7 @@ func GetUserByAuth(authToken string, authType Type) *user.User { Type: string(authType), }) if err != nil { + log.Errorln(err) return nil } diff --git a/controllers/auth/fediverse/fediverse.go b/controllers/auth/fediverse/fediverse.go new file mode 100644 index 000000000..e335a5a81 --- /dev/null +++ b/controllers/auth/fediverse/fediverse.go @@ -0,0 +1,98 @@ +package fediverse + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/owncast/owncast/activitypub" + "github.com/owncast/owncast/auth" + "github.com/owncast/owncast/auth/fediverse" + fediverseauth "github.com/owncast/owncast/auth/fediverse" + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/core/chat" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/core/user" + log "github.com/sirupsen/logrus" +) + +// RegisterFediverseOTPRequest registers a new OTP request for the given access token. +func RegisterFediverseOTPRequest(u user.User, w http.ResponseWriter, r *http.Request) { + type request struct { + FediverseAccount string `json:"account"` + } + var req request + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&req); err != nil { + controllers.WriteSimpleResponse(w, false, "Could not decode request: "+err.Error()) + return + } + + accessToken := r.URL.Query().Get("accessToken") + reg := fediverseauth.RegisterFediverseOTP(accessToken, u.ID, u.DisplayName, req.FediverseAccount) + msg := fmt.Sprintf("

This is an automated message from %s. If you did not request this message please ignore or block. Your requested one-time code is:

%s

", data.GetServerName(), reg.Code) + if err := activitypub.SendDirectFederatedMessage(msg, reg.Account); err != nil { + controllers.WriteSimpleResponse(w, false, "Could not send code to fediverse: "+err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "") +} + +// VerifyFediverseOTPRequest verifies the given OTP code for the given access token. +func VerifyFediverseOTPRequest(w http.ResponseWriter, r *http.Request) { + type request struct { + Code string `json:"code"` + } + + var req request + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&req); err != nil { + controllers.WriteSimpleResponse(w, false, "Could not decode request: "+err.Error()) + return + } + accessToken := r.URL.Query().Get("accessToken") + valid, authRegistration := fediverse.ValidateFediverseOTP(accessToken, req.Code) + if !valid { + w.WriteHeader(http.StatusForbidden) + return + } + + // Check if a user with this auth already exists, if so, log them in. + if u := auth.GetUserByAuth(authRegistration.Account, auth.Fediverse); u != nil { + // Handle existing auth. + log.Debugln("user with provided fedvierse identity already exists, logging them in") + + // Update the current user's access token to point to the existing user id. + userID := u.ID + if err := user.SetAccessTokenToOwner(accessToken, userID); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + loginMessage := fmt.Sprintf("**%s** is now authenticated as **%s**", authRegistration.UserDisplayName, u.DisplayName) + if err := chat.SendSystemAction(loginMessage, true); err != nil { + log.Errorln(err) + } + + controllers.WriteSimpleResponse(w, true, "") + + return + } + + // Otherwise, save this as new auth. + log.Debug("fediverse account does not already exist, saving it as a new one for the current user") + if err := auth.AddAuth(authRegistration.UserID, authRegistration.Account, auth.Fediverse); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + // Update the current user's authenticated flag so we can show it in + // the chat UI. + if err := user.SetUserAsAuthenticated(authRegistration.UserID); err != nil { + log.Errorln(err) + } + + controllers.WriteSimpleResponse(w, true, "") + w.WriteHeader(http.StatusOK) +} diff --git a/controllers/remoteFollow.go b/controllers/remoteFollow.go index 9ea585cc6..fdd012cce 100644 --- a/controllers/remoteFollow.go +++ b/controllers/remoteFollow.go @@ -7,6 +7,7 @@ import ( "net/url" "strings" + "github.com/owncast/owncast/activitypub/webfinger" "github.com/owncast/owncast/core/data" ) @@ -35,7 +36,7 @@ func RemoteFollow(w http.ResponseWriter, r *http.Request) { localActorPath, _ := url.Parse(data.GetServerURL()) localActorPath.Path = fmt.Sprintf("/federation/user/%s", data.GetDefaultFederationUsername()) var template string - links, err := getWebfingerLinks(request.Account) + links, err := webfinger.GetWebfingerLinks(request.Account) if err != nil { WriteSimpleResponse(w, false, err.Error()) return @@ -62,39 +63,3 @@ func RemoteFollow(w http.ResponseWriter, r *http.Request) { WriteResponse(w, response) } - -func getWebfingerLinks(account string) ([]map[string]interface{}, error) { - type webfingerResponse struct { - Links []map[string]interface{} `json:"links"` - } - - account = strings.TrimLeft(account, "@") // remove any leading @ - accountComponents := strings.Split(account, "@") - fediverseServer := accountComponents[1] - - // HTTPS is required. - requestURL, err := url.Parse("https://" + fediverseServer) - if err != nil { - return nil, fmt.Errorf("unable to parse fediverse server host %s", fediverseServer) - } - - requestURL.Path = "/.well-known/webfinger" - query := requestURL.Query() - query.Add("resource", fmt.Sprintf("acct:%s", account)) - requestURL.RawQuery = query.Encode() - - response, err := http.DefaultClient.Get(requestURL.String()) - if err != nil { - return nil, err - } - - defer response.Body.Close() - - var links webfingerResponse - decoder := json.NewDecoder(response.Body) - if err := decoder.Decode(&links); err != nil { - return nil, err - } - - return links.Links, nil -} diff --git a/router/router.go b/router/router.go index ee033611a..de5390a77 100644 --- a/router/router.go +++ b/router/router.go @@ -13,6 +13,7 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers/admin" + fediverseauth "github.com/owncast/owncast/controllers/auth/fediverse" "github.com/owncast/owncast/controllers/auth/indieauth" "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/data" @@ -355,10 +356,11 @@ func Start() error { // Start auth flow http.HandleFunc("/api/auth/indieauth", middleware.RequireUserAccessToken(indieauth.StartAuthFlow)) http.HandleFunc("/api/auth/indieauth/callback", indieauth.HandleRedirect) - - // Handle auth provider requests http.HandleFunc("/api/auth/provider/indieauth", indieauth.HandleAuthEndpoint) + http.HandleFunc("/api/auth/fediverse", middleware.RequireUserAccessToken(fediverseauth.RegisterFediverseOTPRequest)) + http.HandleFunc("/api/auth/fediverse/verify", fediverseauth.VerifyFediverseOTPRequest) + // ActivityPub has its own router activitypub.Start(data.GetDatastore()) diff --git a/webroot/img/indieauth.png b/webroot/img/indieauth.png index bb7943b885ed37921a0f4acbd5eff580d154600d..8f1beee9c76fdbabe9625f511abe3f74ebb0882f 100644 GIT binary patch literal 10089 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 literal 6668 zcmeAS@N?(olHy`uVBq!ia0y~yV4TLlz{J48#=yX^P~R(*fq_A?#5JNMI6tkVJh3R1 z!7(L2DOJHUH!(dmC^a#qvhZZ84FiMZI!_nJkcwMx?)nyqxZh)cu&DX(mpKie;sj?1 z@3>K!Uc+>R(?ETJW#dDZ4q=J+TQ=7)off(-zu58>wbN7U$462&QJOK>-0>W=e^5p(mM3y3%}#o}d_EZmt)sw^j?MY(D?W{4*rn3LW0Q`f|C@gn$Ryw5c`g5O z?Z@V20UI|&uV~SF8sU8)NB7`Ygu{1)JZSFy_2nsV1Ov$LRMZmIwO;byNgT zSLRs2m29SihfJ@vC+5FX zJlHgwX_EhikiSi>*=zU@|6}P+ILOPWmeJIzdFuPV%^x|`GMZS;nN!j)ur#&y?~&kq z8OGpqg@s#7%wyGcmL}Gx)-zUcicNVtLD&0qlk3(+oDN5oB0NqOf1hy8$nH>og5hqa zJK;^OdwVvoS}M)J(w*S=y#3G31L8VorA!!o7F}m)S{iEhE^$#pr}tS7F;VUbJxkLL zUYhp9Vu_^aj2)a}XO=Axm^O>myVzzclUjZg>s&dmOW8~ZFU@ikKCq?FFZ`YH!Am~r z;cE{h?zqmLifv!Q#hZ+x-3gBA?B8c4B-;6zv^C!^ZVnUBNegJG_S>+5b+fv~ z8unQZ8?Ky_{=OhdQ0K*yfDMkq9MiHaFG{5C{_gXFrF*9EgeeJvI#)j>sqFa9!ky;( z;GXk_C)pQR9^4bwQF-__e#T$9H(Q$$0=t;sIUYQun>f=6e3u>P1n3_@|>5TsOgkrHoRXVn zDMXykl=@iCD2+LO!wf;jm0xE$Y?xvq z%(d{*0_zjAd20?XiekL8yy>V9$BF9;I9a;+xK%`_uNBns$~mxCE8^BH#+6|UWTvfU z?UodHP$#PM_G6L?huFcUKCa#((_`$GNxOwsiBAEyZS--_T0 zw%h(dr|snL!h#)VGQ?`+n)5`p?+6(Af1R82KK-ClMB4M--<7dP3@jztKXl)zzxl&@ zAK!clqw~kl7pCvj5tlo1vuWvu&YtGO_oEWU7BW>B^IcJ&`em_zp1$?$|C_(B-so?8 ziKi*`+(CsYXZL4zHd{&Cug{8Q?`IUAc9Ly+)wWfaDl{X~Mp0~Ywd?|aqsha4$+Nw2@ zAv&w3R_*CJ9q07)^sA>jAq5*JC0l`_%_?5@keyS->&m5?dA!q4e-!=}yYKy*y*`n$ zvn;r#MMkvys-HQszi7&_FvibzVftFTGr!Do*svnvl<2&=syUYhby!EI!n{jfISjBYggP@ zG2cAKrfW~~h9ky|f;y8oi)`B|+%J+YsJHr@_mp?#2PZAJsJ?j7toYf}<7-+zAJuWs z%fGR{otcL%77|YF}qO6`bH2rYruq-#qHj zUj2w?#u-gp&+Uzk-*SC!?;mT~x$ExyZ*pC3m9ljaXVrERsI&9Ere6AFtB{E;2I5$6Sedq?2$GyMxD_5?In|3%vr^z+l zYRBgv$IME@bkpbh{+Z1)%VEQtvz^D%w)7TRJbjzY)O3{R=z`{x&PHJdET`|~T&q20 z6v6!Hy8MoFO|BbWxUhfRtd?PSIlXR9#-CR2`vNPI z7DnGXAg+_9#wJxJ5I@aiqJbI5v?Dop>R*-nCKxp)2|seQPz+l=>%f!X z4KsRu9{x9R++25+B^y+c&F+r3&-(i%SbP?P-NkICgNvdBb#{C^#(8kj5!Qo~^pD$d zPRr$)b>IQhDzSr$Hv60?-*6!6?D{+ov8B2RLRt|eA~WV@U9C`z`1a(e$B!xQ@AEJI z(rQmIWM(`#DNa9PNA9zSF3AbezWEQhr|ssQ_2Afr(31zNeD6y)xw7AEe=1<0D5_(< zp^!iRm1$#F+=dkqJbF969pgJV>15C0qW*)e`hVSp@2tGZTXQh!*v5YLSqJtunhEKo z$!LkiRD;SelME(vzNS?7(~B>MPJXKr!80dMX4|8Hu;h+}!p}Pj3(jtxnsBluq44pB zd3%qaHT@|!2P|&z(&(d*j#Z0eTjdekt#RHtIg0BbA@uR5`EmAa5A;*TDhNz zU)0tLC!eiN356FuqqZKA)Nl3U3DKU+QYQSkCqYmrxawWOsU&v$$xM%X5Uil@zwGlhw>rV^j`u4=?y!Vy z*m0wkW8tST<0tlvFLG|oKEX9DWsijAUiEvqC#yC@&rFc*NhnK@rmRET$(p8dOc z#fv&&Ewvq5V&aqE)>=HOmGL;A^tI?>#e$Z)KZ_kSqo#U)TFcfgz46a$@m15qAD_Rz zR=59X*KNf~YCCUcm43b_{k^n8k~!_F*McMazxpp!iYO{ue^Iq%zm@;lE63s z_C12_?~bfQ20rf;{o{>4|Ni)Vx8m|XeVqWO2{u>5X9wP8`&TPIv8&a6^5N?W?OM-L z{`{HqdXjTZ&#&D-tXKQpy2L&0Q{|N%XSLMc1pX0lRtquM-#35r&S|F~8C@4RoFbs+ zyLz2bZejn^{J%4@oi?;+J^TK#*v{3p_-*T*^~pzVN~YFd6ppSo`L?%M)O+SaqZj*Y z)ObD|zy9k)=#7}#f8zfyE^92ldR*?v|8Lh<37QG~>P*o5@#vsh;^#`&k3pLa7OwhM zC$1;g;JH5G=G_JVnU1L)oaA!H;qBLWh0BJkW_?__wCm_Uu7n;T9WP&p4II;IEH6qJ zxN(YYW;`IF8et@2ess?gwkJ7}r`hVXMNcm{4=NugO%hjTJg5{AqqD#=)Tc?em2n0O z_q1G#ixL_h^8TEHI%y0H$JR`1I@&$a!ISNv(qG0PtvcrYuM!Fu873I%L`<3D@VaY0 z&viMSX$Kn~P5Eb%{q|teV>ts&F&#a=3wsuse^|{sZQ~T4g^yg44=jn;u*0AsK~Trb zvtfzXy#wnlBiLevH6rvQrc7ec6y5zQD0PFyMF|5-F&#aQ1IxSSDqNS+iR)qM5|5uP zpra+$u*d- zD;M>+dHE;oT{P{dxRjhib*)W8_y!rTnxsY7zcYk(Cj_o#$VmgWyA_tQH$C03r;z{3 zxu6+Bf({!*H0NdT1ndopV_Cm2Nl>R@mFotT)OywoS3&)z)e#Im|7@Ax+_#Emy z=8)}Gk+AYhm+J^NJS!!^V%OU9WkCZhDo+r8|g6!WC z%*Mnqt*Phf{h$pDPj)xPCmmG^VKAzDvg1gC(`)f|!%-^jhv&`R}Jo)?M=ju7Cb{A!4)YQL<V{;keh&_*to~ z`>(_|Nn_U2vN?A)uU)-#o%v3i%llmq#Ms`e5t5JPdzfV3mYed?sPdh6;={jFNKP`n$NXPZ$Nav}L$+Vu_7BCf@3*bLx%KaiM1Qz>Lo*`pLE6Jzlk?iQKh)-%QTp5Pp~cL& z*BsOGp7H*f(|ok^m`bhmzDE-{H($Q~FxkFMuep7mPV2kd9TdZH?}gmn*ZeBp@}v-np37|9*K{z?2cER|$A z{mX5kTy$-s7(4gO0^NC~^8MfTSC!lhG%B4@T9l{RquDBXS-PJ?T+k@|{qdN$$M?L^ znL0J+nqkSr=Kp-KIv zmRN(;*=>#ULpL1hJselh5U_EH(F<;drlnnN3@gsYNdMT$+TB~-a7g{NmRLiTZH86E zqn)njw3z}nt_Wi=GTkDl<7dL=p|g(7R9dH5k6}X0<%3C@JPLc8*R!Pxg5OxY4<`}PFMnBs<$TLpEB z=Oi*;SkdhJ==r(`7VZOyGH)`b?`%H$kINxdxKfncLHgI?%U2!u`2=oYSP}J#Lrh(Y zS>}%8vAeEs>lGOOTby9~x9FR_KtuhPt?b=@MH{$|-;D$nw+x>xj{N%;BifzCy5Q2s z@C^)GQa}BDyR+%2^mhiWX-n9yi#cpidFYbdQ0Z&ScRp|S>luNB?mh*rRqA zTxdj`;+f5T#=-C=9sO6-L%3(@g*B7422dnu5vxfq!7`?JMEw;%ZXDe+Mev3AfK!Nl|f&l zk1gKT#UrNg#$*z-Rmbb^QAUp>4ZVmt89WoVKGF7SUlGy5<*=_ShD$oeydmf7O7jo= zf;wjJ7^PN4h3GI$5e7wthSQ~~OIK=(UFB-%QoS$2;;^COio^m2_vNb>PDzlA(QDYY zYTC&r)zn{%20K%t#e>6irX6HBb#b}?$e>e(Qv`IHU$X@Cg~XrQG2_Mb=w&*q87w+# znwM_g&lIpoC0Zhw_4I>Z3@ajnejWSDsNpwh(SaZdhW{QL8~4w6F#VOax)k%2Rp&P* zNydmbBs~q^SQ6L6qH*(H^^;(h?!Tf8qTWHxubBeYq~6g!uMG0of~?3VUrsY9MA)T2 zmRnF$o%>)p-?W2Y88qVNyMn@X!Mudd;V(I;p9ezQ)|4^@T}=gu8>&kKc3LiP0=54G2TU9uxa;qX3JYK%8ZBD(=#*j0KyPOT=(Hm;=W-CES zcFpEuu)5M;vCT(=Bp6=TY%bk!5dek@Pi0U+#F(=Gsda^EI zVX)W|||V)4Yu- zftmk34=DIHwDwLr6r#ti z_Dn4AYf$!Qu;xzpjpAZfSX$rw^p&9@!;Qm-UyF998ZcZ6)tTE{&G5}UhJTu4HN%GW z{GcrV&Xk?Ovk8<@93CY|ny@lxHXl7^%i!63^x$L$6<#q00i6RWdM1p&*}DCXh@3NR zOjvj~;b&~{gsOyJk8fXHnr5K6;BG?YdQFcvKEhM9{kVF#-^l4CtM4)pm~cbdH(<|v zQ%36@d-hfS_o|+-DoRpads@(`^-+FS)w*vCR>s7hpf-_EkrHChuiiv}22xY^e8)f_)c+a*S8T)LiR4WM82Zb3-{K=f^d1y+qZ+ zS#0I4ryj3X{cv=aUx}z58^^Y{A?mXh-72UQOkXUg_`F!rM=th$_}=o{|9`(IG3~LB zEAz8|_RN9Pzkctxb6(QN3>Wq%AKG{=B%2Im;J6tSo%Ic z)7;}+a{DjSkgZHhZ#G26Neln7P_sC*?VrNVj^qRDx9e8_-_O9zQ1v#$Yn97sRR#tI N22WQ%mvv4FO#oVH35);$ diff --git a/webroot/js/app.js b/webroot/js/app.js index 1aa235d02..a29ed9e5b 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -6,7 +6,6 @@ import { URL_WEBSOCKET } from './utils/constants.js'; import { OwncastPlayer } from './components/player.js'; import SocialIconsList from './components/platform-logos-list.js'; -import UsernameForm from './components/chat/username.js'; import VideoPoster from './components/video-poster.js'; import Followers from './components/federation/followers.js'; import Chat from './components/chat/chat.js'; @@ -635,7 +634,7 @@ export default class App extends Component { showAuthModal() { const data = { - title: 'Chat', + title: 'Authenticate with chat', }; this.setState({ authModalData: data }); } @@ -664,6 +663,7 @@ export default class App extends Component { // user details are so we can display them properly. const { user } = e; const { displayName, authenticated } = user; + this.setState({ username: displayName, authenticated, @@ -909,6 +909,7 @@ export default class App extends Component { authenticated=${authenticated} onClose=${this.closeAuthModal} indieAuthEnabled=${indieAuthEnabled} + federationEnabled=${federation.enabled} />`} /> `; @@ -1082,6 +1083,7 @@ export default class App extends Component { ${chat} ${externalActionModal} ${fediverseFollowModal} ${notificationModal} ${authModal} + `; } diff --git a/webroot/js/chat/indieauth.js b/webroot/js/chat/indieauth.js deleted file mode 100644 index f87bd9197..000000000 --- a/webroot/js/chat/indieauth.js +++ /dev/null @@ -1,3 +0,0 @@ -import { URL_CHAT_INDIEAUTH_BEGIN } from '../utils/constants.js'; - -export async function beginIndieAuthFlow() {} diff --git a/webroot/js/components/auth-fediverse.js b/webroot/js/components/auth-fediverse.js new file mode 100644 index 000000000..374749728 --- /dev/null +++ b/webroot/js/components/auth-fediverse.js @@ -0,0 +1,206 @@ +import { h, Component } from '/js/web_modules/preact.js'; +import htm from '/js/web_modules/htm.js'; + +const html = htm.bind(h); + +export default class FediverseAuth extends Component { + constructor(props) { + super(props); + + this.submitButtonPressed = this.submitButtonPressed.bind(this); + + this.state = { + account: '', + code: '', + errorMessage: null, + loading: false, + verifying: false, + valid: false, + }; + } + + async makeRequest(url, data) { + const rawResponse = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + const content = await rawResponse.json(); + if (content.message) { + this.setState({ errorMessage: content.message, loading: false }); + return; + } + } + + switchToCodeVerify() { + this.setState({ verifying: true, loading: false }); + } + + async validateCodeButtonPressed() { + const { accessToken } = this.props; + const { code } = this.state; + + this.setState({ loading: true, errorMessage: null }); + + const url = `/api/auth/fediverse/verify?accessToken=${accessToken}`; + const data = { code: code }; + + try { + await this.makeRequest(url, data); + + // Success. Reload the page. + window.location = '/'; + } catch (e) { + console.error(e); + this.setState({ errorMessage: e, loading: false }); + } + } + + async registerAccountButtonPressed() { + const { accessToken } = this.props; + const { account, valid } = this.state; + + if (!valid) { + return; + } + + const url = `/api/auth/fediverse?accessToken=${accessToken}`; + const normalizedAccount = account.replace(/^@+/, ''); + const data = { account: normalizedAccount }; + + this.setState({ loading: true, errorMessage: null }); + + try { + await this.makeRequest(url, data); + this.switchToCodeVerify(); + } catch (e) { + console.error(e); + this.setState({ errorMessage: e, loading: false }); + } + } + + async submitButtonPressed() { + const { verifying } = this.state; + if (verifying) { + this.validateCodeButtonPressed(); + } else { + this.registerAccountButtonPressed(); + } + } + + onInput = (e) => { + const { value } = e.target; + const { verifying } = this.state; + + if (verifying) { + this.setState({ code: value }); + return; + } + + const valid = validateAccount(value); + this.setState({ account: value, valid }); + }; + + render() { + const { errorMessage, account, code, valid, loading, verifying } = + this.state; + const { authenticated, username } = this.props; + const buttonState = valid ? '' : 'cursor-not-allowed opacity-50'; + + const loaderStyle = loading ? 'flex' : 'none'; + const message = verifying + ? 'Paste in the code that was sent to your Fediverse account. If you did not receive a code, make sure you can accept direct messages.' + : !authenticated + ? html`Receive a direct message from on the Fediverse to ${' '} link your + account to ${' '} ${username}, or login + as a previously linked chat user.` + : html`You are already authenticated. However, you can add other + accounts or log in as a different user.`; + const label = verifying ? 'Code' : 'Your fediverse account'; + const placeholder = verifying ? '123456' : 'youraccount@fediverse.server'; + const buttonText = verifying ? 'Verify' : 'Authenticate with Fediverse'; + + const error = errorMessage + ? html` ` + : null; + + return html` +
+

${message}

+ + ${error} + +
+ + + +
+ +

+

+ + Learn more about using the Fediverse to authenticate with chat. + +
+

+ You can link your chat identity with your Fediverse identity. + Next time you want to use this chat identity you can again go + through the Fediverse authentication. +

+
+
+

+ +
+ +

Authenticating.

+

Please wait...

+
+
+ `; + } +} + +function validateAccount(account) { + account = account.replace(/^@+/, ''); + var regex = + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return regex.test(String(account).toLowerCase()); +} diff --git a/webroot/js/components/auth-indieauth.js b/webroot/js/components/auth-indieauth.js index 2e1ccb4e0..74b93bfec 100644 --- a/webroot/js/components/auth-indieauth.js +++ b/webroot/js/components/auth-indieauth.js @@ -16,7 +16,7 @@ export default class IndieAuthForm extends Component { } async submitButtonPressed() { - const { accessToken, authenticated } = this.props; + const { accessToken } = this.props; const { host, valid } = this.state; if (!valid) { @@ -68,17 +68,17 @@ export default class IndieAuthForm extends Component { render() { const { errorMessage, loading, host, valid } = this.state; - const { authenticated } = this.props; + const { authenticated, username } = this.props; const buttonState = valid ? '' : 'cursor-not-allowed opacity-50'; const loaderStyle = loading ? 'flex' : 'none'; const message = !authenticated - ? `While you can chat completely anonymously you can also add - authentication so you can rejoin with the same chat persona from any - device or browser.` + ? html`Use your own domain to authenticate ${' '} + ${username} or login as a previously + ${' '} authenticated chat user using IndieAuth.` : html`You are already authenticated. However, you can add other - external sites or log in as a different user.`; let errorMessageText = errorMessage; @@ -134,7 +134,7 @@ export default class IndieAuthForm extends Component {

- Learn more about IndieAuth + Learn more about using IndieAuth to authenticate with chat.

@@ -153,11 +153,6 @@ export default class IndieAuthForm extends Component {

-

- Note: This is for authentication purposes only, and no personal - information will be accessed or stored. -

-
{ + const { value } = e.target; + let valid = validateURL(value); + this.setState({ host: value, valid }); + }; + + render() { + const { errorMessage, host, valid, loading } = this.state; + const { authenticated } = this.props; + const buttonState = valid ? '' : 'cursor-not-allowed opacity-50'; + + const loaderStyle = loading ? 'flex' : 'none'; + + const message = !authenticated + ? `While you can chat completely anonymously you can also add + authentication so you can rejoin with the same chat persona from any + device or browser.` + : `You are already authenticated, however you can add other external sites or accounts to your chat account or log in as a different user.`; + + const error = errorMessage + ? html` ` + : null; + + return html` +
+

${message}

+ + ${error} + +
+ + + +

+ Learn more about + IndieAuth. +

+
+ +
+ +

Authenticating.

+

Please wait...

+
+
+ `; + } +} + +function validateURL(url) { + if (!url) { + return false; + } + + try { + const u = new URL(url); + if (!u) { + return false; + } + + if (u.protocol !== 'https:') { + return false; + } + } catch (e) { + return false; + } + + return true; +} diff --git a/webroot/js/components/chat-settings-modal.js b/webroot/js/components/chat-settings-modal.js index 67ea5089d..f07848570 100644 --- a/webroot/js/components/chat-settings-modal.js +++ b/webroot/js/components/chat-settings-modal.js @@ -2,6 +2,7 @@ import { h, Component } from '/js/web_modules/preact.js'; import htm from '/js/web_modules/htm.js'; import TabBar from './tab-bar.js'; import IndieAuthForm from './auth-indieauth.js'; +import FediverseAuth from './auth-fediverse.js'; const html = htm.bind(h); @@ -10,13 +11,14 @@ export default class ChatSettingsModal extends Component { const { accessToken, authenticated, + federationEnabled, username, - onUsernameChange, indieAuthEnabled, } = this.props; - const TAB_CONTENT = [ - { + const TAB_CONTENT = []; + if (indieAuthEnabled) { + TAB_CONTENT.push({ label: html``, - }, - ]; + }); + } + + if (federationEnabled) { + TAB_CONTENT.push({ + label: html` + FediAuth`, + content: html`<${FediverseAuth}} + authenticated=${authenticated} + accessToken=${accessToken} + authenticated=${authenticated} + username=${username} + />`, + }); + } return html`
<${TabBar} tabs=${TAB_CONTENT} ariaLabel="Chat settings" /> +

+ Note: This is for authentication purposes only, and no personal + information will be accessed or stored. +

`; } diff --git a/webroot/styles/app.css b/webroot/styles/app.css index 425023db2..70b666c82 100644 --- a/webroot/styles/app.css +++ b/webroot/styles/app.css @@ -572,6 +572,7 @@ header { width: 100%; height: 100%; opacity: 0.7; + z-index: 100; } #follow-loading-spinner {