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
@ -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()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
}
|
||||
|
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
|
||||
|
||||
// The different auth types we support.
|
||||
// Currently only IndieAuth.
|
||||
const (
|
||||
// IndieAuth https://indieauth.spec.indieweb.org/.
|
||||
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),
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
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"
|
||||
"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
|
||||
}
|
||||
|
@ -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())
|
||||
|
||||
|
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 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}
|
||||
|
||||
</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() {
|
||||
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 ${' '}
|
||||
<span class="font-bold">${username}</span> or login as a previously
|
||||
${' '} authenticated chat user using IndieAuth.`
|
||||
: html`<span
|
||||
><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;
|
||||
@ -134,7 +134,7 @@ export default class IndieAuthForm extends Component {
|
||||
<p class="mt-4">
|
||||
<details>
|
||||
<summary class="cursor-pointer">
|
||||
Learn more about <span class="text-blue-500">IndieAuth</span>
|
||||
Learn more about using IndieAuth to authenticate with chat.
|
||||
</summary>
|
||||
<div class="inline">
|
||||
<p class="mt-4">
|
||||
@ -153,11 +153,6 @@ export default class IndieAuthForm extends Component {
|
||||
</details>
|
||||
</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
|
||||
id="follow-loading-spinner-container"
|
||||
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 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`<span style=${{ display: 'flex', alignItems: 'center' }}
|
||||
><img
|
||||
style=${{
|
||||
@ -31,13 +33,40 @@ export default class ChatSettingsModal extends Component {
|
||||
content: html`<${IndieAuthForm}}
|
||||
accessToken=${accessToken}
|
||||
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`
|
||||
<div class="bg-gray-100 bg-center bg-no-repeat p-5">
|
||||
<${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>
|
||||
`;
|
||||
}
|
||||
|
@ -572,6 +572,7 @@ header {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.7;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#follow-loading-spinner {
|
||||
|
Loading…
Reference in New Issue
Block a user