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:
Gabe Kangas 2022-04-22 17:23:14 -07:00 committed by GitHub
parent 8b7e2b945e
commit a082cf3a77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 855 additions and 81 deletions

View file

@ -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()

View file

@ -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
} }

View file

@ -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
}

View file

@ -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.

View file

@ -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
}

View 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
}

View file

@ -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"
) )

View 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
}

View 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")
}
}

View file

@ -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
} }

View 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)
}

View file

@ -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
}

View file

@ -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

View file

@ -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>
`; `;
} }

View file

@ -1,3 +0,0 @@
import { URL_CHAT_INDIEAUTH_BEGIN } from '../utils/constants.js';
export async function beginIndieAuthFlow() {}

View 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());
}

View file

@ -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}"

View 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;
}

View file

@ -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>
`; `;
} }

View file

@ -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 {