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 bb7943b88..8f1beee9c 100644 Binary files a/webroot/img/indieauth.png and b/webroot/img/indieauth.png differ 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 {