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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 J)!K(}#D9mB#vT9w literal 6668 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 DT4@Q4 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 {