mirror of
https://github.com/owncast/owncast.git
synced 2024-11-25 06:12:23 +03:00
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
This commit is contained in:
parent
8b7e2b945e
commit
a082cf3a77
21 changed files with 855 additions and 81 deletions
|
@ -40,6 +40,11 @@ func SendPublicFederatedMessage(message string) error {
|
||||||
return outbox.SendPublicMessage(message)
|
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.
|
// GetFollowerCount will return the local tracked follower count.
|
||||||
func GetFollowerCount() (int64, error) {
|
func GetFollowerCount() (int64, error) {
|
||||||
return persistence.GetFollowerCount()
|
return persistence.GetFollowerCount()
|
||||||
|
|
|
@ -17,13 +17,76 @@ const (
|
||||||
PUBLIC PrivacyAudience = "https://www.w3.org/ns/activitystreams#Public"
|
PUBLIC PrivacyAudience = "https://www.w3.org/ns/activitystreams#Public"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MakeCreateActivity will return a new Create activity with the provided ID.
|
// MakeNotePublic ses the required proeprties to make this note seen as public.
|
||||||
func MakeCreateActivity(activityID *url.URL) vocab.ActivityStreamsCreate {
|
func MakeNotePublic(note vocab.ActivityStreamsNote) vocab.ActivityStreamsNote {
|
||||||
activity := streams.NewActivityStreamsCreate()
|
public, _ := url.Parse(PUBLIC)
|
||||||
id := streams.NewJSONLDIdProperty()
|
to := streams.NewActivityStreamsToProperty()
|
||||||
id.Set(activityID)
|
to.AppendIRI(public)
|
||||||
activity.SetJSONLDId(id)
|
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".
|
// TO the public if we're not treating ActivityPub as "private".
|
||||||
if !data.GetFederationIsPrivate() {
|
if !data.GetFederationIsPrivate() {
|
||||||
public, _ := url.Parse(PUBLIC)
|
public, _ := url.Parse(PUBLIC)
|
||||||
|
@ -40,6 +103,16 @@ func MakeCreateActivity(activityID *url.URL) vocab.ActivityStreamsCreate {
|
||||||
return activity
|
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.
|
// MakeUpdateActivity will return a new Update activity with the provided aID.
|
||||||
func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate {
|
func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate {
|
||||||
activity := streams.NewActivityStreamsUpdate()
|
activity := streams.NewActivityStreamsUpdate()
|
||||||
|
@ -61,9 +134,11 @@ func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate {
|
||||||
// MakeNote will return a new Note object.
|
// MakeNote will return a new Note object.
|
||||||
func MakeNote(text string, noteIRI *url.URL, attributedToIRI *url.URL) vocab.ActivityStreamsNote {
|
func MakeNote(text string, noteIRI *url.URL, attributedToIRI *url.URL) vocab.ActivityStreamsNote {
|
||||||
note := streams.NewActivityStreamsNote()
|
note := streams.NewActivityStreamsNote()
|
||||||
|
|
||||||
content := streams.NewActivityStreamsContentProperty()
|
content := streams.NewActivityStreamsContentProperty()
|
||||||
content.AppendXMLSchemaString(text)
|
content.AppendXMLSchemaString(text)
|
||||||
note.SetActivityStreamsContent(content)
|
note.SetActivityStreamsContent(content)
|
||||||
|
|
||||||
id := streams.NewJSONLDIdProperty()
|
id := streams.NewJSONLDIdProperty()
|
||||||
id.Set(noteIRI)
|
id.Set(noteIRI)
|
||||||
note.SetJSONLDId(id)
|
note.SetJSONLDId(id)
|
||||||
|
@ -77,17 +152,5 @@ func MakeNote(text string, noteIRI *url.URL, attributedToIRI *url.URL) vocab.Act
|
||||||
attr.AppendIRI(attributedTo)
|
attr.AppendIRI(attributedTo)
|
||||||
note.SetActivityStreamsAttributedTo(attr)
|
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
|
return note
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,11 @@ type WebfingerResponse struct {
|
||||||
Links []Link `json:"links"`
|
Links []Link `json:"links"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebfingerProfileRequestResponse represents a Webfinger profile request response.
|
||||||
|
type WebfingerProfileRequestResponse struct {
|
||||||
|
Self string
|
||||||
|
}
|
||||||
|
|
||||||
// Link represents a Webfinger response Link entity.
|
// Link represents a Webfinger response Link entity.
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Rel string `json:"rel"`
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package outbox
|
package outbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -13,7 +12,11 @@ import (
|
||||||
"github.com/owncast/owncast/activitypub/apmodels"
|
"github.com/owncast/owncast/activitypub/apmodels"
|
||||||
"github.com/owncast/owncast/activitypub/crypto"
|
"github.com/owncast/owncast/activitypub/crypto"
|
||||||
"github.com/owncast/owncast/activitypub/persistence"
|
"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/owncast/owncast/activitypub/workerpool"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/owncast/owncast/core/data"
|
"github.com/owncast/owncast/core/data"
|
||||||
|
@ -61,6 +64,12 @@ func SendLive() error {
|
||||||
|
|
||||||
activity, _, note, noteID := createBaseOutboundMessage(textContent)
|
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)
|
note.SetActivityStreamsTag(tagProp)
|
||||||
|
|
||||||
// Attach an image along with the Federated message.
|
// Attach an image along with the Federated message.
|
||||||
|
@ -106,6 +115,37 @@ func SendLive() error {
|
||||||
return nil
|
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.
|
// SendPublicMessage will send a public message to all followers.
|
||||||
func SendPublicMessage(textContent string) error {
|
func SendPublicMessage(textContent string) error {
|
||||||
originalContent := textContent
|
originalContent := textContent
|
||||||
|
@ -191,6 +231,20 @@ func SendToFollowers(payload []byte) error {
|
||||||
return nil
|
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.
|
// UpdateFollowersWithAccountUpdates will send an update to all followers alerting of a profile update.
|
||||||
func UpdateFollowersWithAccountUpdates() error {
|
func UpdateFollowersWithAccountUpdates() error {
|
||||||
// Don't do anything if federation is disabled.
|
// Don't do anything if federation is disabled.
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
package requests
|
package requests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/go-fed/activity/streams"
|
"github.com/go-fed/activity/streams"
|
||||||
"github.com/go-fed/activity/streams/vocab"
|
"github.com/go-fed/activity/streams/vocab"
|
||||||
"github.com/owncast/owncast/activitypub/crypto"
|
"github.com/owncast/owncast/activitypub/crypto"
|
||||||
|
"github.com/owncast/owncast/config"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
@ -50,3 +54,21 @@ func WriteResponse(payload []byte, w http.ResponseWriter, publicKey crypto.Publi
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
46
activitypub/webfinger/webfinger.go
Normal file
46
activitypub/webfinger/webfinger.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -4,8 +4,8 @@ package auth
|
||||||
type Type string
|
type Type string
|
||||||
|
|
||||||
// The different auth types we support.
|
// The different auth types we support.
|
||||||
// Currently only IndieAuth.
|
|
||||||
const (
|
const (
|
||||||
// IndieAuth https://indieauth.spec.indieweb.org/.
|
// IndieAuth https://indieauth.spec.indieweb.org/.
|
||||||
IndieAuth Type = "indieauth"
|
IndieAuth Type = "indieauth"
|
||||||
|
Fediverse Type = "fediverse"
|
||||||
)
|
)
|
||||||
|
|
63
auth/fediverse/fediverse.go
Normal file
63
auth/fediverse/fediverse.go
Normal file
|
@ -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
|
||||||
|
}
|
43
auth/fediverse/fediverse_test.go
Normal file
43
auth/fediverse/fediverse_test.go
Normal file
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -55,6 +55,7 @@ func GetUserByAuth(authToken string, authType Type) *user.User {
|
||||||
Type: string(authType),
|
Type: string(authType),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
98
controllers/auth/fediverse/fediverse.go
Normal file
98
controllers/auth/fediverse/fediverse.go
Normal file
|
@ -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("<p>This is an automated message from %s. If you did not request this message please ignore or block. Your requested one-time code is:</p><p>%s</p>", 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)
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/owncast/owncast/activitypub/webfinger"
|
||||||
"github.com/owncast/owncast/core/data"
|
"github.com/owncast/owncast/core/data"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -35,7 +36,7 @@ func RemoteFollow(w http.ResponseWriter, r *http.Request) {
|
||||||
localActorPath, _ := url.Parse(data.GetServerURL())
|
localActorPath, _ := url.Parse(data.GetServerURL())
|
||||||
localActorPath.Path = fmt.Sprintf("/federation/user/%s", data.GetDefaultFederationUsername())
|
localActorPath.Path = fmt.Sprintf("/federation/user/%s", data.GetDefaultFederationUsername())
|
||||||
var template string
|
var template string
|
||||||
links, err := getWebfingerLinks(request.Account)
|
links, err := webfinger.GetWebfingerLinks(request.Account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteSimpleResponse(w, false, err.Error())
|
WriteSimpleResponse(w, false, err.Error())
|
||||||
return
|
return
|
||||||
|
@ -62,39 +63,3 @@ func RemoteFollow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
WriteResponse(w, response)
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/owncast/owncast/controllers"
|
"github.com/owncast/owncast/controllers"
|
||||||
"github.com/owncast/owncast/controllers/admin"
|
"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/controllers/auth/indieauth"
|
||||||
"github.com/owncast/owncast/core/chat"
|
"github.com/owncast/owncast/core/chat"
|
||||||
"github.com/owncast/owncast/core/data"
|
"github.com/owncast/owncast/core/data"
|
||||||
|
@ -355,10 +356,11 @@ func Start() error {
|
||||||
// Start auth flow
|
// Start auth flow
|
||||||
http.HandleFunc("/api/auth/indieauth", middleware.RequireUserAccessToken(indieauth.StartAuthFlow))
|
http.HandleFunc("/api/auth/indieauth", middleware.RequireUserAccessToken(indieauth.StartAuthFlow))
|
||||||
http.HandleFunc("/api/auth/indieauth/callback", indieauth.HandleRedirect)
|
http.HandleFunc("/api/auth/indieauth/callback", indieauth.HandleRedirect)
|
||||||
|
|
||||||
// Handle auth provider requests
|
|
||||||
http.HandleFunc("/api/auth/provider/indieauth", indieauth.HandleAuthEndpoint)
|
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 has its own router
|
||||||
activitypub.Start(data.GetDatastore())
|
activitypub.Start(data.GetDatastore())
|
||||||
|
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 9.9 KiB |
|
@ -6,7 +6,6 @@ import { URL_WEBSOCKET } from './utils/constants.js';
|
||||||
|
|
||||||
import { OwncastPlayer } from './components/player.js';
|
import { OwncastPlayer } from './components/player.js';
|
||||||
import SocialIconsList from './components/platform-logos-list.js';
|
import SocialIconsList from './components/platform-logos-list.js';
|
||||||
import UsernameForm from './components/chat/username.js';
|
|
||||||
import VideoPoster from './components/video-poster.js';
|
import VideoPoster from './components/video-poster.js';
|
||||||
import Followers from './components/federation/followers.js';
|
import Followers from './components/federation/followers.js';
|
||||||
import Chat from './components/chat/chat.js';
|
import Chat from './components/chat/chat.js';
|
||||||
|
@ -635,7 +634,7 @@ export default class App extends Component {
|
||||||
|
|
||||||
showAuthModal() {
|
showAuthModal() {
|
||||||
const data = {
|
const data = {
|
||||||
title: 'Chat',
|
title: 'Authenticate with chat',
|
||||||
};
|
};
|
||||||
this.setState({ authModalData: data });
|
this.setState({ authModalData: data });
|
||||||
}
|
}
|
||||||
|
@ -664,6 +663,7 @@ export default class App extends Component {
|
||||||
// user details are so we can display them properly.
|
// user details are so we can display them properly.
|
||||||
const { user } = e;
|
const { user } = e;
|
||||||
const { displayName, authenticated } = user;
|
const { displayName, authenticated } = user;
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
username: displayName,
|
username: displayName,
|
||||||
authenticated,
|
authenticated,
|
||||||
|
@ -909,6 +909,7 @@ export default class App extends Component {
|
||||||
authenticated=${authenticated}
|
authenticated=${authenticated}
|
||||||
onClose=${this.closeAuthModal}
|
onClose=${this.closeAuthModal}
|
||||||
indieAuthEnabled=${indieAuthEnabled}
|
indieAuthEnabled=${indieAuthEnabled}
|
||||||
|
federationEnabled=${federation.enabled}
|
||||||
/>`}
|
/>`}
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
|
@ -1082,6 +1083,7 @@ export default class App extends Component {
|
||||||
|
|
||||||
${chat} ${externalActionModal} ${fediverseFollowModal}
|
${chat} ${externalActionModal} ${fediverseFollowModal}
|
||||||
${notificationModal} ${authModal}
|
${notificationModal} ${authModal}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
import { URL_CHAT_INDIEAUTH_BEGIN } from '../utils/constants.js';
|
|
||||||
|
|
||||||
export async function beginIndieAuthFlow() {}
|
|
206
webroot/js/components/auth-fediverse.js
Normal file
206
webroot/js/components/auth-fediverse.js
Normal file
|
@ -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 ${' '} <span class="font-bold">${username}</span>, or login
|
||||||
|
as a previously linked chat user.`
|
||||||
|
: html`<span
|
||||||
|
><b>You are already authenticated</b>. However, you can add other
|
||||||
|
accounts or log in as a different user.</span
|
||||||
|
>`;
|
||||||
|
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` <div
|
||||||
|
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div class="font-bold mb-2">There was an error.</div>
|
||||||
|
<div class="block mt-2">
|
||||||
|
Server error:
|
||||||
|
<div>${errorMessage}</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="bg-gray-100 bg-center bg-no-repeat">
|
||||||
|
<p class="text-gray-700 text-md">${message}</p>
|
||||||
|
|
||||||
|
${error}
|
||||||
|
|
||||||
|
<div class="mb34">
|
||||||
|
<label
|
||||||
|
class="block text-gray-700 text-sm font-semibold mt-6"
|
||||||
|
for="username"
|
||||||
|
>
|
||||||
|
${label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
onInput=${this.onInput}
|
||||||
|
type="url"
|
||||||
|
value=${verifying ? code : account}
|
||||||
|
class="border bg-white rounded w-full py-2 px-3 mb-2 mt-2 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder=${placeholder}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="bg-indigo-500 hover:bg-indigo-600 text-white font-bold py-2 mt-6 px-4 rounded focus:outline-none focus:shadow-outline ${buttonState}"
|
||||||
|
type="button"
|
||||||
|
onClick=${this.submitButtonPressed}
|
||||||
|
>
|
||||||
|
${buttonText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-4">
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer">
|
||||||
|
Learn more about using the Fediverse to authenticate with chat.
|
||||||
|
</summary>
|
||||||
|
<div class="inline">
|
||||||
|
<p class="mt-4">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="follow-loading-spinner-container"
|
||||||
|
style="display: ${loaderStyle}"
|
||||||
|
>
|
||||||
|
<img id="follow-loading-spinner" src="/img/loading.gif" />
|
||||||
|
<p class="text-gray-700 text-lg">Authenticating.</p>
|
||||||
|
<p class="text-gray-600 text-lg">Please wait...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ export default class IndieAuthForm extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitButtonPressed() {
|
async submitButtonPressed() {
|
||||||
const { accessToken, authenticated } = this.props;
|
const { accessToken } = this.props;
|
||||||
const { host, valid } = this.state;
|
const { host, valid } = this.state;
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
@ -68,17 +68,17 @@ export default class IndieAuthForm extends Component {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { errorMessage, loading, host, valid } = this.state;
|
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 buttonState = valid ? '' : 'cursor-not-allowed opacity-50';
|
||||||
const loaderStyle = loading ? 'flex' : 'none';
|
const loaderStyle = loading ? 'flex' : 'none';
|
||||||
|
|
||||||
const message = !authenticated
|
const message = !authenticated
|
||||||
? `While you can chat completely anonymously you can also add
|
? html`Use your own domain to authenticate ${' '}
|
||||||
authentication so you can rejoin with the same chat persona from any
|
<span class="font-bold">${username}</span> or login as a previously
|
||||||
device or browser.`
|
${' '} authenticated chat user using IndieAuth.`
|
||||||
: html`<span
|
: html`<span
|
||||||
><b>You are already authenticated</b>. However, you can add other
|
><b>You are already authenticated</b>. However, you can add other
|
||||||
external sites or log in as a different user.</span
|
domains or log in as a different user.</span
|
||||||
>`;
|
>`;
|
||||||
|
|
||||||
let errorMessageText = errorMessage;
|
let errorMessageText = errorMessage;
|
||||||
|
@ -134,7 +134,7 @@ export default class IndieAuthForm extends Component {
|
||||||
<p class="mt-4">
|
<p class="mt-4">
|
||||||
<details>
|
<details>
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
Learn more about <span class="text-blue-500">IndieAuth</span>
|
Learn more about using IndieAuth to authenticate with chat.
|
||||||
</summary>
|
</summary>
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<p class="mt-4">
|
<p class="mt-4">
|
||||||
|
@ -153,11 +153,6 @@ export default class IndieAuthForm extends Component {
|
||||||
</details>
|
</details>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="mt-4">
|
|
||||||
<b>Note:</b> This is for authentication purposes only, and no personal
|
|
||||||
information will be accessed or stored.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="follow-loading-spinner-container"
|
id="follow-loading-spinner-container"
|
||||||
style="display: ${loaderStyle}"
|
style="display: ${loaderStyle}"
|
||||||
|
|
162
webroot/js/components/auth-modal.js
Normal file
162
webroot/js/components/auth-modal.js
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
import { h, Component } from '/js/web_modules/preact.js';
|
||||||
|
import htm from '/js/web_modules/htm.js';
|
||||||
|
import { ExternalActionButton } from './external-action-modal.js';
|
||||||
|
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
export default class AuthModal extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.submitButtonPressed = this.submitButtonPressed.bind(this);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
errorMessage: null,
|
||||||
|
loading: false,
|
||||||
|
valid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitButtonPressed() {
|
||||||
|
const { accessToken } = this.props;
|
||||||
|
const { host, valid } = this.state;
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/auth/indieauth?accessToken=${accessToken}`;
|
||||||
|
const data = { authHost: host };
|
||||||
|
|
||||||
|
this.setState({ loading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
} else if (!content.redirect) {
|
||||||
|
this.setState({
|
||||||
|
errorMessage: 'Auth provider did not return a redirect URL.',
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirect = content.redirect;
|
||||||
|
window.location = redirect;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
this.setState({ errorMessage: e, loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onInput = (e) => {
|
||||||
|
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` <div
|
||||||
|
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div class="font-bold mb-2">There was an error.</div>
|
||||||
|
<div class="block mt-2">
|
||||||
|
Server error:
|
||||||
|
<div>${errorMessage}</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="bg-gray-100 bg-center bg-no-repeat p-4">
|
||||||
|
<p class="text-gray-700 text-md">${message}</p>
|
||||||
|
|
||||||
|
${error}
|
||||||
|
|
||||||
|
<div class="mb34">
|
||||||
|
<label
|
||||||
|
class="block text-gray-700 text-sm font-semibold mt-6"
|
||||||
|
for="username"
|
||||||
|
>
|
||||||
|
IndieAuth with your own site
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
onInput=${this.onInput}
|
||||||
|
type="url"
|
||||||
|
value=${host}
|
||||||
|
class="border bg-white rounded w-full py-2 px-3 mb-2 mt-2 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://yoursite.com"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="bg-indigo-500 hover:bg-indigo-600 text-white font-bold py-2 mt-6 px-4 rounded focus:outline-none focus:shadow-outline ${buttonState}"
|
||||||
|
type="button"
|
||||||
|
onClick=${this.submitButtonPressed}
|
||||||
|
>
|
||||||
|
Authenticate with IndieAuth
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
Learn more about
|
||||||
|
<a class="underline" href="https://indieauth.net/">IndieAuth</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="follow-loading-spinner-container"
|
||||||
|
style="display: ${loaderStyle}"
|
||||||
|
>
|
||||||
|
<img id="follow-loading-spinner" src="/img/loading.gif" />
|
||||||
|
<p class="text-gray-700 text-lg">Authenticating.</p>
|
||||||
|
<p class="text-gray-600 text-lg">Please wait...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { h, Component } from '/js/web_modules/preact.js';
|
||||||
import htm from '/js/web_modules/htm.js';
|
import htm from '/js/web_modules/htm.js';
|
||||||
import TabBar from './tab-bar.js';
|
import TabBar from './tab-bar.js';
|
||||||
import IndieAuthForm from './auth-indieauth.js';
|
import IndieAuthForm from './auth-indieauth.js';
|
||||||
|
import FediverseAuth from './auth-fediverse.js';
|
||||||
|
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
@ -10,13 +11,14 @@ export default class ChatSettingsModal extends Component {
|
||||||
const {
|
const {
|
||||||
accessToken,
|
accessToken,
|
||||||
authenticated,
|
authenticated,
|
||||||
|
federationEnabled,
|
||||||
username,
|
username,
|
||||||
onUsernameChange,
|
|
||||||
indieAuthEnabled,
|
indieAuthEnabled,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const TAB_CONTENT = [
|
const TAB_CONTENT = [];
|
||||||
{
|
if (indieAuthEnabled) {
|
||||||
|
TAB_CONTENT.push({
|
||||||
label: html`<span style=${{ display: 'flex', alignItems: 'center' }}
|
label: html`<span style=${{ display: 'flex', alignItems: 'center' }}
|
||||||
><img
|
><img
|
||||||
style=${{
|
style=${{
|
||||||
|
@ -31,13 +33,40 @@ export default class ChatSettingsModal extends Component {
|
||||||
content: html`<${IndieAuthForm}}
|
content: html`<${IndieAuthForm}}
|
||||||
accessToken=${accessToken}
|
accessToken=${accessToken}
|
||||||
authenticated=${authenticated}
|
authenticated=${authenticated}
|
||||||
|
username=${username}
|
||||||
/>`,
|
/>`,
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
|
if (federationEnabled) {
|
||||||
|
TAB_CONTENT.push({
|
||||||
|
label: html`<span style=${{ display: 'flex', alignItems: 'center' }}
|
||||||
|
><img
|
||||||
|
style=${{
|
||||||
|
display: 'inline',
|
||||||
|
height: '0.8em',
|
||||||
|
marginRight: '5px',
|
||||||
|
}}
|
||||||
|
src="/img/fediverse-black.png"
|
||||||
|
/>
|
||||||
|
FediAuth</span
|
||||||
|
>`,
|
||||||
|
content: html`<${FediverseAuth}}
|
||||||
|
authenticated=${authenticated}
|
||||||
|
accessToken=${accessToken}
|
||||||
|
authenticated=${authenticated}
|
||||||
|
username=${username}
|
||||||
|
/>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="bg-gray-100 bg-center bg-no-repeat p-5">
|
<div class="bg-gray-100 bg-center bg-no-repeat p-5">
|
||||||
<${TabBar} tabs=${TAB_CONTENT} ariaLabel="Chat settings" />
|
<${TabBar} tabs=${TAB_CONTENT} ariaLabel="Chat settings" />
|
||||||
|
<p class="mt-4">
|
||||||
|
<b>Note:</b> This is for authentication purposes only, and no personal
|
||||||
|
information will be accessed or stored.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -572,6 +572,7 @@ header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
#follow-loading-spinner {
|
#follow-loading-spinner {
|
||||||
|
|
Loading…
Reference in a new issue