From b835de2dc4c2289f5dc368b747da01c78bf97d9b Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Thu, 21 Apr 2022 14:55:26 -0700 Subject: [PATCH] IndieAuth support (#1811) * 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 * don't redirect unless a URL is present avoids redirecting to `undefined` if there was an error * improve error message if owncast server URL isn't set * fix IndieAuth PKCE implementation use SHA256 instead of SHA1, generates a longer code verifier (must be 43-128 chars long), fixes URL-safe SHA256 encoding * return real profile data for IndieAuth response * check the code verifier in the IndieAuth server * Linting * Add new chat settings modal anad split up indieauth ui * Remove logging error * Update the IndieAuth modal UI. For #1273 * Add IndieAuth repsonse error checking * Disable IndieAuth client if server URL is not set. * Add explicit error messages for specific error types * Fix bad logic * Return OAuth-keyed error responses for indieauth server * Display IndieAuth error in plain text with link to return to main page * Remove redundant check * Add additional detail to error * Hide IndieAuth details behind disclosure details * Break out migration into two steps because some people have been runing dev in production * Add auth option to user dropdown Co-authored-by: Aaron Parecki --- auth/auth.go | 11 + auth/indieauth/client.go | 112 ++++++ auth/indieauth/helpers.go | 120 +++++++ auth/indieauth/random.go | 34 ++ auth/indieauth/request.go | 18 + auth/indieauth/response.go | 18 + auth/indieauth/server.go | 92 +++++ auth/persistence.go | 77 +++++ controllers/auth/indieauth/client.go | 103 ++++++ controllers/auth/indieauth/server.go | 80 +++++ controllers/chat.go | 12 +- controllers/config.go | 42 ++- controllers/controllers.go | 8 + controllers/notifications.go | 3 +- core/chat/events.go | 20 +- core/chat/events/nameChangeEvent.go | 1 + core/chat/persistence.go | 46 +-- core/chat/server.go | 2 +- core/core.go | 2 + core/data/data.go | 5 +- core/data/migrations.go | 70 ++++ core/data/users.go | 21 +- core/user/externalAPIUser.go | 47 ++- core/user/user.go | 156 ++++++--- db/models.go | 28 ++ db/query.sql | 26 ++ db/query.sql.go | 166 +++++++++ db/schema.sql | 30 ++ go.mod | 2 + go.sum | 33 +- router/middleware/auth.go | 7 +- router/middleware/headers.go | 2 +- router/router.go | 10 + static/metadata.html.tmpl | 2 + test/automated/api/integrations.test.js | 2 +- webroot/img/authenticated.svg | 38 ++ webroot/img/indieauth.png | Bin 0 -> 6668 bytes webroot/img/user-settings.svg | 2 + webroot/index.html | 327 ++++++++++++------ webroot/js/app.js | 70 +++- webroot/js/chat/indieauth.js | 3 + webroot/js/components/auth-indieauth.js | 192 ++++++++++ webroot/js/components/chat-settings-modal.js | 44 +++ webroot/js/components/chat/chat-menu.js | 13 +- .../js/components/chat/chat-message-view.js | 16 +- webroot/js/components/chat/username.js | 4 + webroot/js/utils/constants.js | 1 + 47 files changed, 1844 insertions(+), 274 deletions(-) create mode 100644 auth/auth.go create mode 100644 auth/indieauth/client.go create mode 100644 auth/indieauth/helpers.go create mode 100644 auth/indieauth/random.go create mode 100644 auth/indieauth/request.go create mode 100644 auth/indieauth/response.go create mode 100644 auth/indieauth/server.go create mode 100644 auth/persistence.go create mode 100644 controllers/auth/indieauth/client.go create mode 100644 controllers/auth/indieauth/server.go create mode 100644 webroot/img/authenticated.svg create mode 100644 webroot/img/indieauth.png create mode 100644 webroot/img/user-settings.svg create mode 100644 webroot/js/chat/indieauth.js create mode 100644 webroot/js/components/auth-indieauth.js create mode 100644 webroot/js/components/chat-settings-modal.js diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 000000000..ce2219452 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,11 @@ +package auth + +// Type represents a form of authentication. +type Type string + +// The different auth types we support. +// Currently only IndieAuth. +const ( + // IndieAuth https://indieauth.spec.indieweb.org/. + IndieAuth Type = "indieauth" +) diff --git a/auth/indieauth/client.go b/auth/indieauth/client.go new file mode 100644 index 000000000..6e70f8c92 --- /dev/null +++ b/auth/indieauth/client.go @@ -0,0 +1,112 @@ +package indieauth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/owncast/owncast/core/data" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +var pendingAuthRequests = make(map[string]*Request) + +// StartAuthFlow will begin the IndieAuth flow by generating an auth request. +func StartAuthFlow(authHost, userID, accessToken, displayName string) (*url.URL, error) { + serverURL := data.GetServerURL() + if serverURL == "" { + return nil, errors.New("Owncast server URL must be set when using auth") + } + + r, err := createAuthRequest(authHost, userID, displayName, accessToken, serverURL) + if err != nil { + return nil, errors.Wrap(err, "unable to generate IndieAuth request") + } + + pendingAuthRequests[r.State] = r + + return r.Redirect, nil +} + +// HandleCallbackCode will handle the callback from the IndieAuth server +// to continue the next step of the auth flow. +func HandleCallbackCode(code, state string) (*Request, *Response, error) { + request, exists := pendingAuthRequests[state] + if !exists { + return nil, nil, errors.New("no auth requests pending") + } + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("client_id", request.ClientID) + data.Set("redirect_uri", request.Callback.String()) + data.Set("code_verifier", request.CodeVerifier) + + client := &http.Client{} + r, err := http.NewRequest("POST", request.Endpoint.String(), strings.NewReader(data.Encode())) // URL-encoded payload + if err != nil { + return nil, nil, err + } + r.Header.Add("Content-Type", "application/x-www-form-urlencoded") + r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) + + res, err := client.Do(r) + if err != nil { + return nil, nil, err + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, nil, err + } + + var response Response + if err := json.Unmarshal(body, &response); err != nil { + return nil, nil, errors.Wrap(err, "unable to parse IndieAuth response") + } + + if response.Error != "" || response.ErrorDescription != "" { + errorText := makeIndieAuthClientErrorText(response.Error) + log.Debugln("IndieAuth error:", response.Error, response.ErrorDescription) + return nil, nil, fmt.Errorf("IndieAuth error: %s - %s", errorText, response.ErrorDescription) + } + + // In case this IndieAuth server does not use OAuth error keys or has internal + // issues resulting in unstructured errors. + if res.StatusCode < 200 || res.StatusCode > 299 { + log.Debugln("IndieAuth error. status code:", res.StatusCode, "body:", string(body)) + return nil, nil, errors.New("there was an error authenticating against IndieAuth server") + } + + // Trim any trailing slash so we can accurately compare the two "me" values + meResponseVerifier := strings.TrimRight(response.Me, "/") + meRequestVerifier := strings.TrimRight(request.Me.String(), "/") + + // What we sent and what we got back must match + if meRequestVerifier != meResponseVerifier { + return nil, nil, errors.New("indieauth response does not match the initial anticipated auth destination") + } + + return request, &response, nil +} + +// Error value should be from this list: +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +func makeIndieAuthClientErrorText(err string) string { + switch err { + case "invalid_request", "invalid_client": + return "The authentication request was invalid. Please report this to the Owncast project." + case "invalid_grant", "unauthorized_client": + return "This authorization request is unauthorized." + case "unsupported_grant_type": + return "The authorization grant type is not supported by the authorization server." + default: + return err + } +} diff --git a/auth/indieauth/helpers.go b/auth/indieauth/helpers.go new file mode 100644 index 000000000..f120327e7 --- /dev/null +++ b/auth/indieauth/helpers.go @@ -0,0 +1,120 @@ +package indieauth + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/andybalholm/cascadia" + "github.com/pkg/errors" + "golang.org/x/net/html" +) + +func createAuthRequest(authDestination, userID, displayName, accessToken, baseServer string) (*Request, error) { + authURL, err := url.Parse(authDestination) + if err != nil { + return nil, errors.Wrap(err, "unable to parse IndieAuth destination") + } + + authEndpointURL, err := getAuthEndpointFromURL(authURL.String()) + if err != nil { + return nil, errors.Wrap(err, "unable to get IndieAuth endpoint from destination URL") + } + + baseServerURL, err := url.Parse(baseServer) + if err != nil { + return nil, errors.Wrap(err, "unable to parse local owncast base server URL") + } + + callbackURL := *baseServerURL + callbackURL.Path = "/api/auth/indieauth/callback" + + codeVerifier := randString(50) + codeChallenge := createCodeChallenge(codeVerifier) + state := randString(20) + responseType := "code" + clientID := baseServerURL.String() // Our local URL + codeChallengeMethod := "S256" + + redirect := *authEndpointURL + + q := authURL.Query() + q.Add("response_type", responseType) + q.Add("client_id", clientID) + q.Add("state", state) + q.Add("code_challenge_method", codeChallengeMethod) + q.Add("code_challenge", codeChallenge) + q.Add("me", authURL.String()) + q.Add("redirect_uri", callbackURL.String()) + redirect.RawQuery = q.Encode() + + return &Request{ + Me: authURL, + UserID: userID, + DisplayName: displayName, + CurrentAccessToken: accessToken, + Endpoint: authEndpointURL, + ClientID: baseServer, + CodeVerifier: codeVerifier, + CodeChallenge: codeChallenge, + State: state, + Redirect: &redirect, + Callback: &callbackURL, + }, nil +} + +func getAuthEndpointFromURL(urlstring string) (*url.URL, error) { + htmlDocScrapeURL, err := url.Parse(urlstring) + if err != nil { + return nil, errors.Wrap(err, "unable to parse URL") + } + + r, err := http.Get(htmlDocScrapeURL.String()) // nolint:gosec + if err != nil { + return nil, err + } + defer r.Body.Close() + + scrapedHTMLDocument, err := html.Parse(r.Body) + if err != nil { + return nil, errors.Wrap(err, "unable to parse html at remote auth host") + } + authorizationEndpointTag := cascadia.MustCompile("link[rel=authorization_endpoint]").MatchAll(scrapedHTMLDocument) + if len(authorizationEndpointTag) == 0 { + return nil, fmt.Errorf("url does not support indieauth") + } + + for _, attr := range authorizationEndpointTag[len(authorizationEndpointTag)-1].Attr { + if attr.Key == "href" { + u, err := url.Parse(attr.Val) + if err != nil { + return nil, errors.Wrap(err, "unable to parse authorization endpoint") + } + + // If it is a relative URL we an fill in the missing components + // by using the original URL we scraped, since it is the same host. + if u.Scheme == "" { + u.Scheme = htmlDocScrapeURL.Scheme + } + + if u.Host == "" { + u.Host = htmlDocScrapeURL.Host + } + + return u, nil + } + } + + return nil, fmt.Errorf("unable to find href value for authorization_endpoint") +} + +func createCodeChallenge(codeVerifier string) string { + sha256hash := sha256.Sum256([]byte(codeVerifier)) + + encodedHashedCode := strings.TrimRight(base64.URLEncoding.EncodeToString(sha256hash[:]), "=") + + return encodedHashedCode +} diff --git a/auth/indieauth/random.go b/auth/indieauth/random.go new file mode 100644 index 000000000..ab0720a89 --- /dev/null +++ b/auth/indieauth/random.go @@ -0,0 +1,34 @@ +package indieauth + +import ( + "math/rand" + "time" + "unsafe" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +const ( + letterIdxBits = 6 // 6 bits to represent a letter index + letterIdxMask = 1<= 0; { + if remain == 0 { + cache, remain = src.Int63(), letterIdxMax + } + if idx := int(cache & letterIdxMask); idx < len(letterBytes) { + b[i] = letterBytes[idx] + i-- + } + cache >>= letterIdxBits + remain-- + } + + return *(*string)(unsafe.Pointer(&b)) // nolint:gosec +} diff --git a/auth/indieauth/request.go b/auth/indieauth/request.go new file mode 100644 index 000000000..a431fc1d6 --- /dev/null +++ b/auth/indieauth/request.go @@ -0,0 +1,18 @@ +package indieauth + +import "net/url" + +// Request represents a single in-flight IndieAuth request. +type Request struct { + UserID string + DisplayName string + CurrentAccessToken string + Endpoint *url.URL + Redirect *url.URL // Outbound redirect URL to continue auth flow + Callback *url.URL // Inbound URL to get auth flow results + ClientID string + CodeVerifier string + CodeChallenge string + State string + Me *url.URL +} diff --git a/auth/indieauth/response.go b/auth/indieauth/response.go new file mode 100644 index 000000000..1c0184951 --- /dev/null +++ b/auth/indieauth/response.go @@ -0,0 +1,18 @@ +package indieauth + +// Profile represents optional profile data that is returned +// when completing the IndieAuth flow. +type Profile struct { + Name string `json:"name"` + URL string `json:"url"` + Photo string `json:"photo"` +} + +// Response the response returned when completing +// the IndieAuth flow. +type Response struct { + Me string `json:"me,omitempty"` + Profile Profile `json:"profile,omitempty"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` +} diff --git a/auth/indieauth/server.go b/auth/indieauth/server.go new file mode 100644 index 000000000..98738a0f6 --- /dev/null +++ b/auth/indieauth/server.go @@ -0,0 +1,92 @@ +package indieauth + +import ( + "fmt" + + "github.com/owncast/owncast/core/data" + "github.com/pkg/errors" + "github.com/teris-io/shortid" +) + +// ServerAuthRequest is n inbound request to authenticate against +// this Owncast instance. +type ServerAuthRequest struct { + ClientID string + RedirectURI string + CodeChallenge string + State string + Me string + Code string +} + +// ServerProfile represents basic user-provided data about this Owncast instance. +type ServerProfile struct { + Name string `json:"name"` + URL string `json:"url"` + Photo string `json:"photo"` +} + +// ServerProfileResponse is returned when an auth flow requests the final +// confirmation of the IndieAuth flow. +type ServerProfileResponse struct { + Me string `json:"me,omitempty"` + Profile ServerProfile `json:"profile,omitempty"` + // Error keys need to match the OAuth spec. + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` +} + +var pendingServerAuthRequests = map[string]ServerAuthRequest{} + +// StartServerAuth will handle the authentication for the admin user of this +// Owncast server. Initiated via a GET of the auth endpoint. +// https://indieweb.org/authorization-endpoint +func StartServerAuth(clientID, redirectURI, codeChallenge, state, me string) (*ServerAuthRequest, error) { + code := shortid.MustGenerate() + + r := ServerAuthRequest{ + ClientID: clientID, + RedirectURI: redirectURI, + CodeChallenge: codeChallenge, + State: state, + Me: me, + Code: code, + } + + pendingServerAuthRequests[code] = r + + return &r, nil +} + +// CompleteServerAuth will verify that the values provided in the final step +// of the IndieAuth flow are correct, and return some basic profile info. +func CompleteServerAuth(code, redirectURI, clientID string, codeVerifier string) (*ServerProfileResponse, error) { + request, pending := pendingServerAuthRequests[code] + if !pending { + return nil, errors.New("no pending authentication request") + } + + if request.RedirectURI != redirectURI { + return nil, errors.New("redirect URI does not match") + } + + if request.ClientID != clientID { + return nil, errors.New("client ID does not match") + } + + codeChallengeFromRequest := createCodeChallenge(codeVerifier) + if request.CodeChallenge != codeChallengeFromRequest { + return nil, errors.New("code verifier is incorrect") + } + + response := ServerProfileResponse{ + Me: data.GetServerURL(), + Profile: ServerProfile{ + Name: data.GetServerName(), + URL: data.GetServerURL(), + Photo: fmt.Sprintf("%s/%s", data.GetServerURL(), data.GetLogoPath()), + }, + } + + return &response, nil +} diff --git a/auth/persistence.go b/auth/persistence.go new file mode 100644 index 000000000..d644d0c25 --- /dev/null +++ b/auth/persistence.go @@ -0,0 +1,77 @@ +package auth + +import ( + "context" + "strings" + + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/core/user" + log "github.com/sirupsen/logrus" + + "github.com/owncast/owncast/db" +) + +var _datastore *data.Datastore + +// Setup will initialize auth persistence. +func Setup(db *data.Datastore) { + _datastore = db + + createTableSQL := `CREATE TABLE IF NOT EXISTS auth ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "type" TEXT NOT NULL, + "timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + );CREATE INDEX auth_token ON auth (token);` + + stmt, err := db.DB.Prepare(createTableSQL) + if err != nil { + log.Fatal(err) + } + defer stmt.Close() + + _, err = stmt.Exec() + if err != nil { + log.Fatalln(err) + } +} + +// AddAuth will add an external authentication token and type for a user. +func AddAuth(userID, authToken string, authType Type) error { + return _datastore.GetQueries().AddAuthForUser(context.Background(), db.AddAuthForUserParams{ + UserID: userID, + Token: authToken, + Type: string(authType), + }) +} + +// GetUserByAuth will return an existing user given auth details if a user +// has previously authenticated with that method. +func GetUserByAuth(authToken string, authType Type) *user.User { + u, err := _datastore.GetQueries().GetUserByAuth(context.Background(), db.GetUserByAuthParams{ + Token: authToken, + Type: string(authType), + }) + if err != nil { + return nil + } + + var scopes []string + if u.Scopes.Valid { + scopes = strings.Split(u.Scopes.String, ",") + } + + return &user.User{ + ID: u.ID, + DisplayName: u.DisplayName, + DisplayColor: int(u.DisplayColor), + CreatedAt: u.CreatedAt.Time, + DisabledAt: &u.DisabledAt.Time, + PreviousNames: strings.Split(u.PreviousNames.String, ","), + NameChangedAt: &u.NamechangedAt.Time, + AuthenticatedAt: &u.AuthenticatedAt.Time, + Scopes: scopes, + } +} diff --git a/controllers/auth/indieauth/client.go b/controllers/auth/indieauth/client.go new file mode 100644 index 000000000..6ea483dbc --- /dev/null +++ b/controllers/auth/indieauth/client.go @@ -0,0 +1,103 @@ +package indieauth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/owncast/owncast/auth" + ia "github.com/owncast/owncast/auth/indieauth" + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/core/chat" + "github.com/owncast/owncast/core/user" + log "github.com/sirupsen/logrus" +) + +// StartAuthFlow will begin the IndieAuth flow for the current user. +func StartAuthFlow(u user.User, w http.ResponseWriter, r *http.Request) { + type request struct { + AuthHost string `json:"authHost"` + } + + type response struct { + Redirect string `json:"redirect"` + } + + var authRequest request + p, err := io.ReadAll(r.Body) + if err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + if err := json.Unmarshal(p, &authRequest); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + accessToken := r.URL.Query().Get("accessToken") + + redirectURL, err := ia.StartAuthFlow(authRequest.AuthHost, u.ID, accessToken, u.DisplayName) + if err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + redirectResponse := response{ + Redirect: redirectURL.String(), + } + controllers.WriteResponse(w, redirectResponse) +} + +// HandleRedirect will handle the redirect from an IndieAuth server to +// continue the auth flow. +func HandleRedirect(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + request, response, err := ia.HandleCallbackCode(code, state) + if err != nil { + log.Debugln(err) + msg := fmt.Sprintf("Unable to complete authentication. Go back.
%s", err.Error()) + _ = controllers.WriteString(w, msg, http.StatusBadRequest) + return + } + + // Check if a user with this auth already exists, if so, log them in. + if u := auth.GetUserByAuth(response.Me, auth.IndieAuth); u != nil { + // Handle existing auth. + log.Debugln("user with provided indieauth already exists, logging them in") + + // Update the current user's access token to point to the existing user id. + accessToken := request.CurrentAccessToken + 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**", request.DisplayName, u.DisplayName) + if err := chat.SendSystemAction(loginMessage, true); err != nil { + log.Errorln(err) + } + + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + + return + } + + // Otherwise, save this as new auth. + log.Debug("indieauth token does not already exist, saving it as a new one for the current user") + if err := auth.AddAuth(request.UserID, response.Me, auth.IndieAuth); 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(request.UserID); err != nil { + log.Errorln(err) + } + + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) +} diff --git a/controllers/auth/indieauth/server.go b/controllers/auth/indieauth/server.go new file mode 100644 index 000000000..78c10a367 --- /dev/null +++ b/controllers/auth/indieauth/server.go @@ -0,0 +1,80 @@ +package indieauth + +import ( + "net/http" + "net/url" + + ia "github.com/owncast/owncast/auth/indieauth" + "github.com/owncast/owncast/controllers" +) + +// HandleAuthEndpoint will handle the IndieAuth auth endpoint. +func HandleAuthEndpoint(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + // Require the GET request for IndieAuth to be behind admin login. + handleAuthEndpointGet(w, r) + } else if r.Method == http.MethodPost { + handleAuthEndpointPost(w, r) + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } +} + +func handleAuthEndpointGet(w http.ResponseWriter, r *http.Request) { + clientID := r.URL.Query().Get("client_id") + redirectURI := r.URL.Query().Get("redirect_uri") + codeChallenge := r.URL.Query().Get("code_challenge") + state := r.URL.Query().Get("state") + me := r.URL.Query().Get("me") + + request, err := ia.StartServerAuth(clientID, redirectURI, codeChallenge, state, me) + if err != nil { + // Return a human readable, HTML page as an error. JSON is no use here. + return + } + + // Redirect the client browser with the values we generated to continue + // the IndieAuth flow. + // If the URL is invalid then return with specific "invalid_request" error. + u, err := url.Parse(redirectURI) + if err != nil { + controllers.WriteResponse(w, ia.Response{ + Error: "invalid_request", + ErrorDescription: err.Error(), + }) + return + } + + redirectParams := u.Query() + redirectParams.Set("code", request.Code) + redirectParams.Set("state", request.State) + u.RawQuery = redirectParams.Encode() + + http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect) +} + +func handleAuthEndpointPost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + code := r.PostForm.Get("code") + redirectURI := r.PostForm.Get("redirect_uri") + clientID := r.PostForm.Get("client_id") + codeVerifier := r.PostForm.Get("code_verifier") + + // If the server auth flow cannot be completed then return with specific + // "invalid_client" error. + response, err := ia.CompleteServerAuth(code, redirectURI, clientID, codeVerifier) + if err != nil { + controllers.WriteResponse(w, ia.Response{ + Error: "invalid_client", + ErrorDescription: err.Error(), + }) + return + } + + controllers.WriteResponse(w, response) +} diff --git a/controllers/chat.go b/controllers/chat.go index a768fcccc..4b1566a23 100644 --- a/controllers/chat.go +++ b/controllers/chat.go @@ -13,11 +13,15 @@ import ( // ExternalGetChatMessages gets all of the chat messages. func ExternalGetChatMessages(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { middleware.EnableCors(w) - GetChatMessages(w, r) + getChatMessages(w, r) } // GetChatMessages gets all of the chat messages. -func GetChatMessages(w http.ResponseWriter, r *http.Request) { +func GetChatMessages(u user.User, w http.ResponseWriter, r *http.Request) { + getChatMessages(w, r) +} + +func getChatMessages(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { @@ -62,7 +66,7 @@ func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) { request.DisplayName = r.Header.Get("X-Forwarded-User") } - newUser, err := user.CreateAnonymousUser(request.DisplayName) + newUser, accessToken, err := user.CreateAnonymousUser(request.DisplayName) if err != nil { WriteSimpleResponse(w, false, err.Error()) return @@ -70,7 +74,7 @@ func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) { response := registerAnonymousUserResponse{ ID: newUser.ID, - AccessToken: newUser.AccessToken, + AccessToken: accessToken, DisplayName: newUser.DisplayName, } diff --git a/controllers/config.go b/controllers/config.go index 187539aee..2e56281bf 100644 --- a/controllers/config.go +++ b/controllers/config.go @@ -16,22 +16,23 @@ import ( ) type webConfigResponse struct { - Name string `json:"name"` - Summary string `json:"summary"` - Logo string `json:"logo"` - Tags []string `json:"tags"` - Version string `json:"version"` - NSFW bool `json:"nsfw"` - SocketHostOverride string `json:"socketHostOverride,omitempty"` - ExtraPageContent string `json:"extraPageContent"` - StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream - SocialHandles []models.SocialHandle `json:"socialHandles"` - ChatDisabled bool `json:"chatDisabled"` - ExternalActions []models.ExternalAction `json:"externalActions"` - CustomStyles string `json:"customStyles"` - MaxSocketPayloadSize int `json:"maxSocketPayloadSize"` - Federation federationConfigResponse `json:"federation"` - Notifications notificationsConfigResponse `json:"notifications"` + Name string `json:"name"` + Summary string `json:"summary"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Version string `json:"version"` + NSFW bool `json:"nsfw"` + SocketHostOverride string `json:"socketHostOverride,omitempty"` + ExtraPageContent string `json:"extraPageContent"` + StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream + SocialHandles []models.SocialHandle `json:"socialHandles"` + ChatDisabled bool `json:"chatDisabled"` + ExternalActions []models.ExternalAction `json:"externalActions"` + CustomStyles string `json:"customStyles"` + MaxSocketPayloadSize int `json:"maxSocketPayloadSize"` + Federation federationConfigResponse `json:"federation"` + Notifications notificationsConfigResponse `json:"notifications"` + Authentication authenticationConfigResponse `json:"authentication"` } type federationConfigResponse struct { @@ -49,6 +50,10 @@ type notificationsConfigResponse struct { Browser browserNotificationsConfigResponse `json:"browser"` } +type authenticationConfigResponse struct { + IndieAuthEnabled bool `json:"indieAuthEnabled"` +} + // GetWebConfig gets the status of the server. func GetWebConfig(w http.ResponseWriter, r *http.Request) { middleware.EnableCors(w) @@ -97,6 +102,10 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) { }, } + authenticationResponse := authenticationConfigResponse{ + IndieAuthEnabled: data.GetServerURL() != "", + } + configuration := webConfigResponse{ Name: data.GetServerName(), Summary: serverSummary, @@ -114,6 +123,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) { MaxSocketPayloadSize: config.MaxSocketPayloadSize, Federation: federationResponse, Notifications: notificationsResponse, + Authentication: authenticationResponse, } if err := json.NewEncoder(w).Encode(configuration); err != nil { diff --git a/controllers/controllers.go b/controllers/controllers.go index fea119656..dc40505ba 100644 --- a/controllers/controllers.go +++ b/controllers/controllers.go @@ -65,3 +65,11 @@ func WriteResponse(w http.ResponseWriter, response interface{}) { InternalErrorHandler(w, err) } } + +// WriteString will return a basic string and a status code to the client. +func WriteString(w http.ResponseWriter, text string, status int) error { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(status) + _, err := w.Write([]byte(text)) + return err +} diff --git a/controllers/notifications.go b/controllers/notifications.go index 19582ea34..08d6757fc 100644 --- a/controllers/notifications.go +++ b/controllers/notifications.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" + "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/notifications" "github.com/owncast/owncast/utils" @@ -13,7 +14,7 @@ import ( // RegisterForLiveNotifications will register a channel + destination to be // notified when a stream goes live. -func RegisterForLiveNotifications(w http.ResponseWriter, r *http.Request) { +func RegisterForLiveNotifications(u user.User, w http.ResponseWriter, r *http.Request) { if r.Method != POST { WriteSimpleResponse(w, false, r.Method+" not supported") return diff --git a/core/chat/events.go b/core/chat/events.go index c118a4f0c..988dead88 100644 --- a/core/chat/events.go +++ b/core/chat/events.go @@ -21,6 +21,8 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { } proposedUsername := receivedEvent.NewName + + // Check if name is on the blocklist blocklist := data.GetForbiddenUsernameList() for _, blockedName := range blocklist { @@ -39,11 +41,27 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { } } + // Check if the name is not already assigned to a registered user. + if available, err := user.IsDisplayNameAvailable(proposedUsername); err != nil { + log.Errorln("error checking if name is available", err) + return + } else if !available { + message := fmt.Sprintf("You cannot change your name to **%s**, it is already in use.", proposedUsername) + s.sendActionToClient(eventData.client, message) + + // Resend the client's user so their username is in sync. + eventData.client.sendConnectedClientInfo() + + return + } + savedUser := user.GetUserByToken(eventData.client.accessToken) oldName := savedUser.DisplayName // Save the new name - user.ChangeUsername(eventData.client.User.ID, receivedEvent.NewName) + if err := user.ChangeUsername(eventData.client.User.ID, receivedEvent.NewName); err != nil { + log.Errorln("error changing username", err) + } // Update the connected clients associated user with the new name now := time.Now() diff --git a/core/chat/events/nameChangeEvent.go b/core/chat/events/nameChangeEvent.go index e782d90b9..379db0adf 100644 --- a/core/chat/events/nameChangeEvent.go +++ b/core/chat/events/nameChangeEvent.go @@ -10,6 +10,7 @@ type NameChangeEvent struct { // NameChangeBroadcast represents a user changing their chat display name. type NameChangeBroadcast struct { Event + OutboundEvent UserEvent Oldname string `json:"oldName"` } diff --git a/core/chat/persistence.go b/core/chat/persistence.go index 62b378f6f..c03b5ae48 100644 --- a/core/chat/persistence.go +++ b/core/chat/persistence.go @@ -104,16 +104,17 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent { scopeSlice := strings.Split(scopes, ",") u := user.User{ - ID: *row.userID, - AccessToken: "", - DisplayName: displayName, - DisplayColor: displayColor, - CreatedAt: createdAt, - DisabledAt: row.userDisabledAt, - NameChangedAt: row.userNameChangedAt, - PreviousNames: previousUsernames, - Scopes: scopeSlice, - IsBot: isBot, + ID: *row.userID, + DisplayName: displayName, + DisplayColor: displayColor, + CreatedAt: createdAt, + DisabledAt: row.userDisabledAt, + NameChangedAt: row.userNameChangedAt, + PreviousNames: previousUsernames, + AuthenticatedAt: row.userAuthenticatedAt, + Authenticated: row.userAuthenticatedAt != nil, + Scopes: scopeSlice, + IsBot: isBot, } message := events.UserMessageEvent{ @@ -195,14 +196,15 @@ type rowData struct { image *string link *string - userDisplayName *string - userDisplayColor *int - userCreatedAt *time.Time - userDisabledAt *time.Time - previousUsernames *string - userNameChangedAt *time.Time - userScopes *string - userType *string + userDisplayName *string + userDisplayColor *int + userCreatedAt *time.Time + userDisabledAt *time.Time + previousUsernames *string + userNameChangedAt *time.Time + userAuthenticatedAt *time.Time + userScopes *string + userType *string } func getChat(query string) []interface{} { @@ -235,9 +237,11 @@ func getChat(query string) []interface{} { &row.userDisabledAt, &row.previousUsernames, &row.userNameChangedAt, + &row.userAuthenticatedAt, &row.userScopes, &row.userType, ); err != nil { + log.Errorln(err) log.Errorln("There is a problem converting query to chat objects. Please report this:", query) break } @@ -274,7 +278,7 @@ func GetChatModerationHistory() []interface{} { } // Get all messages regardless of visibility - query := "SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes, users.type FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC" + query := "SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC" result := getChat(query) _historyCache = &result @@ -285,7 +289,7 @@ func GetChatModerationHistory() []interface{} { // GetChatHistory will return all the chat messages suitable for returning as user-facing chat history. func GetChatHistory() []interface{} { // Get all visible messages - query := fmt.Sprintf("SELECT messages.id,messages.user_id, messages.body, messages.title, messages.subtitle, messages.image, messages.link, messages.eventType, messages.hidden_at, messages.timestamp, users.display_name, users.display_color, users.created_at, users.disabled_at, users.previous_names, users.namechanged_at, users.scopes, users.type FROM messages LEFT JOIN users ON messages.user_id = users.id WHERE hidden_at IS NULL AND disabled_at IS NULL ORDER BY timestamp DESC LIMIT %d", maxBacklogNumber) + query := fmt.Sprintf("SELECT messages.id,messages.user_id, messages.body, messages.title, messages.subtitle, messages.image, messages.link, messages.eventType, messages.hidden_at, messages.timestamp, users.display_name, users.display_color, users.created_at, users.disabled_at, users.previous_names, users.namechanged_at, users.authenticated_at, users.scopes, users.type FROM messages LEFT JOIN users ON messages.user_id = users.id WHERE hidden_at IS NULL AND disabled_at IS NULL ORDER BY timestamp DESC LIMIT %d", maxBacklogNumber) m := getChat(query) // Invert order of messages @@ -305,7 +309,7 @@ func SetMessageVisibilityForUserID(userID string, visible bool) error { // Get a list of IDs to send to the connected clients to hide ids := make([]string, 0) - query := fmt.Sprintf("SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS '%s'", userID) + query := fmt.Sprintf("SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, authenticated, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS '%s'", userID) messages := getChat(query) if len(messages) == 0 { diff --git a/core/chat/server.go b/core/chat/server.go index 648713014..ac22864dd 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -201,10 +201,10 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request) // A user is required to use the websocket user := user.GetUserByToken(accessToken) if user == nil { + // Send error that registration is required _ = conn.WriteJSON(events.EventPayload{ "type": events.ErrorNeedsRegistration, }) - // Send error that registration is required _ = conn.Close() return } diff --git a/core/core.go b/core/core.go index b01ede6b1..576a9121d 100644 --- a/core/core.go +++ b/core/core.go @@ -7,6 +7,7 @@ import ( log "github.com/sirupsen/logrus" + "github.com/owncast/owncast/auth" "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/data" @@ -56,6 +57,7 @@ func Start() error { } user.SetupUsers() + auth.Setup(data.GetDatastore()) fileWriter.SetupFileWriterReceiverService(&handler) diff --git a/core/data/data.go b/core/data/data.go index 3b2d61d19..e4f0d504b 100644 --- a/core/data/data.go +++ b/core/data/data.go @@ -17,7 +17,7 @@ import ( ) const ( - schemaVersion = 4 + schemaVersion = 5 ) var ( @@ -75,6 +75,7 @@ func SetupPersistence(file string) error { createWebhooksTable() createUsersTable(db) + createAccessTokenTable(db) if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config ( "key" string NOT NULL PRIMARY KEY, @@ -141,6 +142,8 @@ func migrateDatabase(db *sql.DB, from, to int) error { migrateToSchema3(db) case 3: migrateToSchema4(db) + case 4: + migrateToSchema5(db) default: log.Fatalln("missing database migration step") } diff --git a/core/data/migrations.go b/core/data/migrations.go index dc7bc7316..d0c4e3905 100644 --- a/core/data/migrations.go +++ b/core/data/migrations.go @@ -2,6 +2,8 @@ package data import ( "database/sql" + "fmt" + "strings" "time" "github.com/owncast/owncast/utils" @@ -9,7 +11,75 @@ import ( "github.com/teris-io/shortid" ) +func migrateToSchema5(db *sql.DB) { + // Access tokens have been broken into its own table. + + // Authenticated bool added to the users table. + stmt, err := db.Prepare("ALTER TABLE users ADD authenticated_at timestamp DEFAULT null ") + if err != nil { + log.Fatal(err) + } + defer stmt.Close() + _, err = stmt.Exec() + if err != nil { + log.Warnln(err) + } + + // Migrate the access tokens from the users table to the access tokens table. + query := `SELECT id, access_token, created_at FROM users` + rows, err := db.Query(query) + if err != nil || rows.Err() != nil { + log.Errorln("error migrating access tokens to schema v5", err, rows.Err()) + return + } + defer rows.Close() + + valueStrings := []string{} + valueArgs := []interface{}{} + + var token string + var userID string + var timestamp time.Time + for rows.Next() { + if err := rows.Scan(&userID, &token, ×tamp); err != nil { + log.Error("There is a problem reading the database.", err) + return + } + + valueStrings = append(valueStrings, "(?, ?, ?)") + valueArgs = append(valueArgs, userID, token, timestamp) + } + + smt := `INSERT INTO user_access_tokens(token, user_id, timestamp) VALUES %s ON CONFLICT DO NOTHING` + smt = fmt.Sprintf(smt, strings.Join(valueStrings, ",")) + // fmt.Println(smt) + tx, err := db.Begin() + if err != nil { + log.Fatalln("Error starting transaction", err) + } + _, err = tx.Exec(smt, valueArgs...) + if err != nil { + _ = tx.Rollback() + log.Fatalln("Error inserting access tokens", err) + } + if err := tx.Commit(); err != nil { + log.Fatalln("Error committing transaction", err) + } + + // Remove old access token column from the users table. + stmt, err = db.Prepare("ALTER TABLE users DROP COLUMN access_token;") + if err != nil { + log.Fatal(err) + } + defer stmt.Close() + _, err = stmt.Exec() + if err != nil { + log.Warnln(err) + } +} + func migrateToSchema4(db *sql.DB) { + // Access tokens have been broken into its own table. stmt, err := db.Prepare("ALTER TABLE ap_followers ADD COLUMN request_object BLOB") if err != nil { log.Fatal(err) diff --git a/core/data/users.go b/core/data/users.go index a6b74d5e5..a8a4e1698 100644 --- a/core/data/users.go +++ b/core/data/users.go @@ -6,18 +6,37 @@ import ( log "github.com/sirupsen/logrus" ) +func createAccessTokenTable(db *sql.DB) { + createTableSQL := `CREATE TABLE IF NOT EXISTS user_access_tokens ( + "token" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + );` + + stmt, err := db.Prepare(createTableSQL) + if err != nil { + log.Fatal(err) + } + defer stmt.Close() + _, err = stmt.Exec() + if err != nil { + log.Warnln(err) + } +} + func createUsersTable(db *sql.DB) { log.Traceln("Creating users table...") createTableSQL := `CREATE TABLE IF NOT EXISTS users ( "id" TEXT, - "access_token" string NOT NULL, "display_name" TEXT NOT NULL, "display_color" NUMBER NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "disabled_at" TIMESTAMP, "previous_names" TEXT DEFAULT '', "namechanged_at" TIMESTAMP, + "authenticated_at" TIMESTAMP, "scopes" TEXT, "type" TEXT DEFAULT 'STANDARD', "last_used" DATETIME DEFAULT CURRENT_TIMESTAMP, diff --git a/core/user/externalAPIUser.go b/core/user/externalAPIUser.go index c3f3fc956..ad6bb9a12 100644 --- a/core/user/externalAPIUser.go +++ b/core/user/externalAPIUser.go @@ -1,12 +1,13 @@ package user import ( + "context" "database/sql" - "errors" "strings" "time" "github.com/owncast/owncast/utils" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/teris-io/shortid" ) @@ -55,13 +56,13 @@ func InsertExternalAPIUser(token string, name string, color int, scopes []string if err != nil { return err } - stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?, ?)") + stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?)") if err != nil { return err } defer stmt.Close() - if _, err = stmt.Exec(id, token, name, color, scopesString, "API", name); err != nil { + if _, err = stmt.Exec(id, name, color, scopesString, "API", name); err != nil { return err } @@ -69,6 +70,10 @@ func InsertExternalAPIUser(token string, name string, color int, scopes []string return err } + if err := addAccessTokenForUser(token, id); err != nil { + return errors.Wrap(err, "unable to save access token for new external api user") + } + return nil } @@ -83,13 +88,13 @@ func DeleteExternalAPIUser(token string) error { if err != nil { return err } - stmt, err := tx.Prepare("UPDATE users SET disabled_at = ? WHERE access_token = ?") + stmt, err := tx.Prepare("UPDATE users SET disabled_at = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)") if err != nil { return err } defer stmt.Close() - result, err := stmt.Exec(time.Now(), token) + result, err := stmt.Exec(token) if err != nil { return err } @@ -112,20 +117,20 @@ func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*Exte // so we can efficiently find if a token supports a single scope. // This is SQLite specific, so if we ever support other database // backends we need to support other methods. - query := `SELECT id, access_token, scopes, display_name, display_color, created_at, last_used FROM ( - WITH RECURSIVE split(id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, scope, rest) AS ( - SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, '', scopes || ',' FROM users + query := `SELECT id, scopes, display_name, display_color, created_at, last_used FROM user_access_tokens, ( + WITH RECURSIVE split(id, scopes, display_name, display_color, created_at, last_used, disabled_at, scope, rest) AS ( + SELECT id, scopes, display_name, display_color, created_at, last_used, disabled_at, '', scopes || ',' FROM users UNION ALL - SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, + SELECT id, scopes, display_name, display_color, created_at, last_used, disabled_at, substr(rest, 0, instr(rest, ',')), substr(rest, instr(rest, ',')+1) FROM split WHERE rest <> '') - SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, scope + SELECT id, scopes, display_name, display_color, created_at, last_used, disabled_at, scope FROM split WHERE scope <> '' - ORDER BY access_token, scope - ) AS token WHERE token.access_token = ? AND token.scope = ?` + ORDER BY scope + ) AS token WHERE user_access_tokens.token = ? AND token.scope = ?` row := _datastore.DB.QueryRow(query, token, scope) integration, err := makeExternalAPIUserFromRow(row) @@ -135,23 +140,18 @@ func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*Exte // GetIntegrationNameForAccessToken will return the integration name associated with a specific access token. func GetIntegrationNameForAccessToken(token string) *string { - query := "SELECT display_name FROM users WHERE access_token IS ? AND disabled_at IS NULL" - row := _datastore.DB.QueryRow(query, token) - - var name string - err := row.Scan(&name) + name, err := _datastore.GetQueries().GetUserDisplayNameByToken(context.Background(), token) if err != nil { - log.Warnln(err) return nil } return &name } -// GetExternalAPIUser will return all access tokens. +// GetExternalAPIUser will return all API users with access tokens. func GetExternalAPIUser() ([]ExternalAPIUser, error) { //nolint // Get all messages sent within the past day - query := "SELECT id, access_token, display_name, display_color, scopes, created_at, last_used FROM users WHERE type IS 'API' AND disabled_at IS NULL" + query := "SELECT id, token, display_name, display_color, scopes, created_at, last_used FROM users, user_access_tokens WHERE user_access_tokens.user_id = id AND type IS 'API' AND disabled_at IS NULL" rows, err := _datastore.DB.Query(query) if err != nil { @@ -170,7 +170,8 @@ func SetExternalAPIUserAccessTokenAsUsed(token string) error { if err != nil { return err } - stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE access_token = ?") + // stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE access_token = ?") + stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)") if err != nil { return err } @@ -189,14 +190,13 @@ func SetExternalAPIUserAccessTokenAsUsed(token string) error { func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) { var id string - var accessToken string var displayName string var displayColor int var scopes string var createdAt time.Time var lastUsedAt *time.Time - err := row.Scan(&id, &accessToken, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt) + err := row.Scan(&id, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt) if err != nil { log.Debugln("unable to convert row to api user", err) return nil, err @@ -204,7 +204,6 @@ func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) { integration := ExternalAPIUser{ ID: id, - AccessToken: accessToken, DisplayName: displayName, DisplayColor: displayColor, CreatedAt: createdAt, diff --git a/core/user/user.go b/core/user/user.go index c546f9889..759e3009d 100644 --- a/core/user/user.go +++ b/core/user/user.go @@ -1,6 +1,7 @@ package user import ( + "context" "database/sql" "fmt" "sort" @@ -8,7 +9,9 @@ import ( "time" "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/db" "github.com/owncast/owncast/utils" + "github.com/pkg/errors" "github.com/teris-io/shortid" log "github.com/sirupsen/logrus" @@ -23,16 +26,17 @@ const ( // User represents a single chat user. type User struct { - ID string `json:"id"` - AccessToken string `json:"-"` - DisplayName string `json:"displayName"` - DisplayColor int `json:"displayColor"` - CreatedAt time.Time `json:"createdAt"` - DisabledAt *time.Time `json:"disabledAt,omitempty"` - PreviousNames []string `json:"previousNames"` - NameChangedAt *time.Time `json:"nameChangedAt,omitempty"` - Scopes []string `json:"scopes,omitempty"` - IsBot bool `json:"isBot"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + DisplayColor int `json:"displayColor"` + CreatedAt time.Time `json:"createdAt"` + DisabledAt *time.Time `json:"disabledAt,omitempty"` + PreviousNames []string `json:"previousNames"` + NameChangedAt *time.Time `json:"nameChangedAt,omitempty"` + Scopes []string `json:"scopes,omitempty"` + IsBot bool `json:"isBot"` + AuthenticatedAt *time.Time `json:"-"` + Authenticated bool `json:"authenticated"` } // IsEnabled will return if this single user is enabled. @@ -52,13 +56,8 @@ func SetupUsers() { } // CreateAnonymousUser will create a new anonymous user with the provided display name. -func CreateAnonymousUser(displayName string) (*User, error) { +func CreateAnonymousUser(displayName string) (*User, string, error) { id := shortid.MustGenerate() - accessToken, err := utils.GenerateAccessToken() - if err != nil { - log.Errorln("Unable to create access token for new user") - return nil, err - } if displayName == "" { suggestedUsernamesList := data.GetSuggestedUsernamesList() @@ -75,48 +74,62 @@ func CreateAnonymousUser(displayName string) (*User, error) { user := &User{ ID: id, - AccessToken: accessToken, DisplayName: displayName, DisplayColor: displayColor, CreatedAt: time.Now(), } + // Create new user. if err := create(user); err != nil { - return nil, err + return nil, "", err } - return user, nil + // Assign it an access token. + accessToken, err := utils.GenerateAccessToken() + if err != nil { + log.Errorln("Unable to create access token for new user") + return nil, "", err + } + if err := addAccessTokenForUser(accessToken, id); err != nil { + return nil, "", errors.Wrap(err, "unable to save access token for new user") + } + + return user, accessToken, nil +} + +// IsDisplayNameAvailable will check if the proposed name is available for use. +func IsDisplayNameAvailable(displayName string) (bool, error) { + if available, err := _datastore.GetQueries().IsDisplayNameAvailable(context.Background(), displayName); err != nil { + return false, errors.Wrap(err, "unable to check if display name is available") + } else if available != 0 { + return false, nil + } + + return true, nil } // ChangeUsername will change the user associated to userID from one display name to another. -func ChangeUsername(userID string, username string) { +func ChangeUsername(userID string, username string) error { _datastore.DbLock.Lock() defer _datastore.DbLock.Unlock() - tx, err := _datastore.DB.Begin() - if err != nil { - log.Debugln(err) - } - defer func() { - if err := tx.Rollback(); err != nil { - log.Debugln(err) - } - }() - - stmt, err := tx.Prepare("UPDATE users SET display_name = ?, previous_names = previous_names || ?, namechanged_at = ? WHERE id = ?") - if err != nil { - log.Debugln(err) - } - defer stmt.Close() - - _, err = stmt.Exec(username, fmt.Sprintf(",%s", username), time.Now(), userID) - if err != nil { - log.Errorln(err) + if err := _datastore.GetQueries().ChangeDisplayName(context.Background(), db.ChangeDisplayNameParams{ + DisplayName: username, + ID: userID, + PreviousNames: sql.NullString{String: fmt.Sprintf(",%s", username), Valid: true}, + NamechangedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }); err != nil { + return errors.Wrap(err, "unable to change display name") } - if err := tx.Commit(); err != nil { - log.Errorln("error changing display name of user", userID, err) - } + return nil +} + +func addAccessTokenForUser(accessToken, userID string) error { + return _datastore.GetQueries().AddAccessTokenForUser(context.Background(), db.AddAccessTokenForUserParams{ + Token: accessToken, + UserID: userID, + }) } func create(user *User) error { @@ -131,15 +144,16 @@ func create(user *User) error { _ = tx.Rollback() }() - stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?, ?)") + stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?)") if err != nil { log.Debugln(err) } defer stmt.Close() - _, err = stmt.Exec(user.ID, user.AccessToken, user.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt) + _, err = stmt.Exec(user.ID, user.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt) if err != nil { log.Errorln("error creating new user", err) + return err } return tx.Commit() @@ -179,13 +193,53 @@ func SetEnabled(userID string, enabled bool) error { // GetUserByToken will return a user by an access token. func GetUserByToken(token string) *User { - _datastore.DbLock.Lock() - defer _datastore.DbLock.Unlock() + u, err := _datastore.GetQueries().GetUserByAccessToken(context.Background(), token) + if err != nil { + return nil + } - query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE access_token = ?" - row := _datastore.DB.QueryRow(query, token) + var scopes []string + if u.Scopes.Valid { + scopes = strings.Split(u.Scopes.String, ",") + } - return getUserFromRow(row) + var disabledAt *time.Time + if u.DisabledAt.Valid { + disabledAt = &u.DisabledAt.Time + } + + var authenticatedAt *time.Time + if u.AuthenticatedAt.Valid { + authenticatedAt = &u.AuthenticatedAt.Time + } + + return &User{ + ID: u.ID, + DisplayName: u.DisplayName, + DisplayColor: int(u.DisplayColor), + CreatedAt: u.CreatedAt.Time, + DisabledAt: disabledAt, + PreviousNames: strings.Split(u.PreviousNames.String, ","), + NameChangedAt: &u.NamechangedAt.Time, + AuthenticatedAt: authenticatedAt, + Authenticated: authenticatedAt != nil, + Scopes: scopes, + } +} + +// SetAccessTokenToOwner will reassign an access token to be owned by a +// different user. Used for logging in with external auth. +func SetAccessTokenToOwner(token, userID string) error { + return _datastore.GetQueries().SetAccessTokenToOwner(context.Background(), db.SetAccessTokenToOwnerParams{ + UserID: userID, + Token: token, + }) +} + +// SetUserAsAuthenticated will mark that a user has been authenticated +// in some way. +func SetUserAsAuthenticated(userID string) error { + return errors.Wrap(_datastore.GetQueries().SetUserAsAuthenticated(context.Background(), userID), "unable to set user as authenticated") } // SetModerator will add or remove moderator status for a single user by ID. @@ -199,6 +253,10 @@ func SetModerator(userID string, isModerator bool) error { func addScopeToUser(userID string, scope string) error { u := GetUserByID(userID) + if u == nil { + return errors.New("user not found when modifying scope") + } + scopesString := u.Scopes scopes := utils.StringSliceToMap(scopesString) scopes[scope] = true diff --git a/db/models.go b/db/models.go index 41b67d0ad..91d932907 100644 --- a/db/models.go +++ b/db/models.go @@ -38,6 +38,14 @@ type ApOutbox struct { LiveNotification sql.NullBool } +type Auth struct { + ID int32 + UserID string + Token string + Type string + Timestamp time.Time +} + type IpBan struct { IpAddress string Notes sql.NullString @@ -50,3 +58,23 @@ type Notification struct { Destination string CreatedAt sql.NullTime } + +type User struct { + ID string + DisplayName string + DisplayColor int32 + CreatedAt sql.NullTime + DisabledAt sql.NullTime + PreviousNames sql.NullString + NamechangedAt sql.NullTime + Scopes sql.NullString + AuthenticatedAt sql.NullTime + Type sql.NullString + LastUsed interface{} +} + +type UserAccessToken struct { + Token string + UserID string + Timestamp time.Time +} diff --git a/db/query.sql b/db/query.sql index a1a3d6d35..6e386f802 100644 --- a/db/query.sql +++ b/db/query.sql @@ -78,3 +78,29 @@ SELECT destination FROM notifications WHERE channel = $1; -- name: RemoveNotificationDestinationForChannel :exec DELETE FROM notifications WHERE channel = $1 AND destination = $2; +-- name: AddAuthForUser :exec +INSERT INTO auth(user_id, token, type) values($1, $2, $3); + +-- name: GetUserByAuth :one +SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM auth, users WHERE token = $1 AND auth.type = $2 AND users.id = auth.user_id; + +-- name: AddAccessTokenForUser :exec +INSERT INTO user_access_tokens(token, user_id) values($1, $2); + +-- name: GetUserByAccessToken :one +SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id; + +-- name: GetUserDisplayNameByToken :one +SELECT display_name FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id AND disabled_at = NULL; + +-- name: SetAccessTokenToOwner :exec +UPDATE user_access_tokens SET user_id = $1 WHERE token = $2; + +-- name: SetUserAsAuthenticated :exec +UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1; + +-- name: IsDisplayNameAvailable :one +SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not null AND disabled_at is NULL; + +-- name: ChangeDisplayName :exec +UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4; diff --git a/db/query.sql.go b/db/query.sql.go index d5d29a416..00975e9eb 100644 --- a/db/query.sql.go +++ b/db/query.sql.go @@ -11,6 +11,35 @@ import ( "time" ) +const addAccessTokenForUser = `-- name: AddAccessTokenForUser :exec +INSERT INTO user_access_tokens(token, user_id) values($1, $2) +` + +type AddAccessTokenForUserParams struct { + Token string + UserID string +} + +func (q *Queries) AddAccessTokenForUser(ctx context.Context, arg AddAccessTokenForUserParams) error { + _, err := q.db.ExecContext(ctx, addAccessTokenForUser, arg.Token, arg.UserID) + return err +} + +const addAuthForUser = `-- name: AddAuthForUser :exec +INSERT INTO auth(user_id, token, type) values($1, $2, $3) +` + +type AddAuthForUserParams struct { + UserID string + Token string + Type string +} + +func (q *Queries) AddAuthForUser(ctx context.Context, arg AddAuthForUserParams) error { + _, err := q.db.ExecContext(ctx, addAuthForUser, arg.UserID, arg.Token, arg.Type) + return err +} + const addFollower = `-- name: AddFollower :exec INSERT INTO ap_followers(iri, inbox, request, request_object, name, username, image, approved_at) values($1, $2, $3, $4, $5, $6, $7, $8) ` @@ -124,6 +153,27 @@ func (q *Queries) BanIPAddress(ctx context.Context, arg BanIPAddressParams) erro return err } +const changeDisplayName = `-- name: ChangeDisplayName :exec +UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4 +` + +type ChangeDisplayNameParams struct { + DisplayName string + PreviousNames sql.NullString + NamechangedAt sql.NullTime + ID string +} + +func (q *Queries) ChangeDisplayName(ctx context.Context, arg ChangeDisplayNameParams) error { + _, err := q.db.ExecContext(ctx, changeDisplayName, + arg.DisplayName, + arg.PreviousNames, + arg.NamechangedAt, + arg.ID, + ) + return err +} + const doesInboundActivityExist = `-- name: DoesInboundActivityExist :one SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TYPE = $3 ` @@ -492,6 +542,99 @@ func (q *Queries) GetRejectedAndBlockedFollowers(ctx context.Context) ([]GetReje return items, nil } +const getUserByAccessToken = `-- name: GetUserByAccessToken :one +SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id +` + +type GetUserByAccessTokenRow struct { + ID string + DisplayName string + DisplayColor int32 + CreatedAt sql.NullTime + DisabledAt sql.NullTime + PreviousNames sql.NullString + NamechangedAt sql.NullTime + AuthenticatedAt sql.NullTime + Scopes sql.NullString +} + +func (q *Queries) GetUserByAccessToken(ctx context.Context, token string) (GetUserByAccessTokenRow, error) { + row := q.db.QueryRowContext(ctx, getUserByAccessToken, token) + var i GetUserByAccessTokenRow + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.DisplayColor, + &i.CreatedAt, + &i.DisabledAt, + &i.PreviousNames, + &i.NamechangedAt, + &i.AuthenticatedAt, + &i.Scopes, + ) + return i, err +} + +const getUserByAuth = `-- name: GetUserByAuth :one +SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM auth, users WHERE token = $1 AND auth.type = $2 AND users.id = auth.user_id +` + +type GetUserByAuthParams struct { + Token string + Type string +} + +type GetUserByAuthRow struct { + ID string + DisplayName string + DisplayColor int32 + CreatedAt sql.NullTime + DisabledAt sql.NullTime + PreviousNames sql.NullString + NamechangedAt sql.NullTime + AuthenticatedAt sql.NullTime + Scopes sql.NullString +} + +func (q *Queries) GetUserByAuth(ctx context.Context, arg GetUserByAuthParams) (GetUserByAuthRow, error) { + row := q.db.QueryRowContext(ctx, getUserByAuth, arg.Token, arg.Type) + var i GetUserByAuthRow + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.DisplayColor, + &i.CreatedAt, + &i.DisabledAt, + &i.PreviousNames, + &i.NamechangedAt, + &i.AuthenticatedAt, + &i.Scopes, + ) + return i, err +} + +const getUserDisplayNameByToken = `-- name: GetUserDisplayNameByToken :one +SELECT display_name FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id AND disabled_at = NULL +` + +func (q *Queries) GetUserDisplayNameByToken(ctx context.Context, token string) (string, error) { + row := q.db.QueryRowContext(ctx, getUserDisplayNameByToken, token) + var display_name string + err := row.Scan(&display_name) + return display_name, err +} + +const isDisplayNameAvailable = `-- name: IsDisplayNameAvailable :one +SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not null AND disabled_at is NULL +` + +func (q *Queries) IsDisplayNameAvailable(ctx context.Context, displayName string) (int64, error) { + row := q.db.QueryRowContext(ctx, isDisplayNameAvailable, displayName) + var count int64 + err := row.Scan(&count) + return count, err +} + const isIPAddressBlocked = `-- name: IsIPAddressBlocked :one SELECT count(*) FROM ip_bans WHERE ip_address = $1 ` @@ -549,6 +692,29 @@ func (q *Queries) RemoveNotificationDestinationForChannel(ctx context.Context, a return err } +const setAccessTokenToOwner = `-- name: SetAccessTokenToOwner :exec +UPDATE user_access_tokens SET user_id = $1 WHERE token = $2 +` + +type SetAccessTokenToOwnerParams struct { + UserID string + Token string +} + +func (q *Queries) SetAccessTokenToOwner(ctx context.Context, arg SetAccessTokenToOwnerParams) error { + _, err := q.db.ExecContext(ctx, setAccessTokenToOwner, arg.UserID, arg.Token) + return err +} + +const setUserAsAuthenticated = `-- name: SetUserAsAuthenticated :exec +UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1 +` + +func (q *Queries) SetUserAsAuthenticated(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, setUserAsAuthenticated, id) + return err +} + const updateFollowerByIRI = `-- name: UpdateFollowerByIRI :exec UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5 ` diff --git a/db/schema.sql b/db/schema.sql index 4e5edb668..33976add6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -49,3 +49,33 @@ CREATE TABLE IF NOT EXISTS notifications ( "destination" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); CREATE INDEX channel_index ON notifications (channel); + +CREATE TABLE IF NOT EXISTS users ( + "id" TEXT, + "display_name" TEXT NOT NULL, + "display_color" INTEGER NOT NULL, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP, + "previous_names" TEXT DEFAULT '', + "namechanged_at" TIMESTAMP, + "scopes" TEXT, + "authenticated_at" TIMESTAMP, + "type" TEXT DEFAULT 'STANDARD', + "last_used" DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) + ); + +CREATE TABLE IF NOT EXISTS user_access_tokens ( + "token" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth ( + "id" INTEGER NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "type" TEXT NOT NULL, + "timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + CREATE INDEX auth_token ON auth (token); diff --git a/go.mod b/go.mod index fc76bca21..5a762c7c1 100644 --- a/go.mod +++ b/go.mod @@ -73,4 +73,6 @@ require ( github.com/oschwald/maxminddb-golang v1.9.0 // indirect ) +require github.com/andybalholm/cascadia v1.3.1 + replace github.com/go-fed/activity => github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026 diff --git a/go.sum b/go.sum index 5497a4e85..29a7b59e3 100644 --- a/go.sum +++ b/go.sum @@ -42,18 +42,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/amalfra/etag v1.0.0 h1:3PNsV45JS4C8SaQ97jxCoUZv22tGlSRF7dgsP9C7yww= github.com/amalfra/etag v1.0.0/go.mod h1:NROjmbfRufDsrJFWcnYxGJSlCtTKn4tXTp2zwyqdSbU= -github.com/aws/aws-sdk-go v1.43.31 h1:yJZIr8nMV1hXjAvvOLUFqZRJcHV7udPQBfhJqawDzI0= -github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/aws/aws-sdk-go v1.43.36 h1:8a+pYKNT7wSxUy3fi5dSqKQdfmit7SYGg5fv4zf+WuA= -github.com/aws/aws-sdk-go v1.43.36/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/aws/aws-sdk-go v1.43.37 h1:kyZ7UjaPZaCik+asF33UFOOYSwr9liDRr/UM/vuw8yY= -github.com/aws/aws-sdk-go v1.43.37/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/aws/aws-sdk-go v1.43.38 h1:TDRjsUIsx2aeSuKkyzbwgltIRTbIKH6YCZbZ27JYhPk= -github.com/aws/aws-sdk-go v1.43.38/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/aws/aws-sdk-go v1.43.39 h1:5W8pton/8OuS5hpbAkzfr7e+meAAFkK7LsUehB39L3I= -github.com/aws/aws-sdk-go v1.43.39/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/aws/aws-sdk-go v1.43.41 h1:HaazVplP8/t6SOfybQlNUmjAxLWDKdLdX8BSEHFlJdY= -github.com/aws/aws-sdk-go v1.43.41/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/aws/aws-sdk-go v1.43.43 h1:1L06qzQvl4aC3Skfh5rV7xVhGHjIZoHcqy16NoyQ1o4= github.com/aws/aws-sdk-go v1.43.43/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -261,8 +251,6 @@ github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5H github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/schollz/sqlite3dump v1.3.1 h1:QXizJ7XEJ7hggjqjZ3YRtF3+javm8zKtzNByYtEkPRA= github.com/schollz/sqlite3dump v1.3.1/go.mod h1:mzSTjZpJH4zAb1FN3iNlhWPbbdyeBpOaTW0hukyMHyI= -github.com/shirou/gopsutil/v3 v3.22.2 h1:wCrArWFkHYIdDxx/FSfF5RB4dpJYW6t7rcp3+zL8uks= -github.com/shirou/gopsutil/v3 v3.22.2/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY= github.com/shirou/gopsutil/v3 v3.22.3 h1:UebRzEomgMpv61e3hgD1tGooqX5trFbdU/ehphbHd00= github.com/shirou/gopsutil/v3 v3.22.3/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -282,12 +270,8 @@ github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMT github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125 h1:3SNcvBmEPE1YlB1JpVZouslJpI3GBNoiqW7+wb0Rz7w= github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= -github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo= -github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs= github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw= github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk= -github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ= -github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8= github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o= github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -375,16 +359,9 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0= -golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 h1:EN5+DfgmRMvRUrMGERW2gQl3Vc+Z7ZMnI/xdEpPSf0c= -golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220412020605-290c469a71a5 h1:bRb386wvrE+oBNdF1d/Xh9mQrfQ4ecYhW5qJ5GvTGT4= -golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2 h1:6mzvA99KwZxbOrxww4EvWVQUnN1+xEu9tafK5ZxkYeA= -golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220420153159-1850ba15e1be h1:yx80W7nvY5ySWpaU8UWaj5o9e23YgO9BRhQol7Lc+JI= golang.org/x/net v0.0.0-20220420153159-1850ba15e1be/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -442,9 +419,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220325203850-36772127a21f h1:TrmogKRsSOxRMJbLYGrB4SBbW+LJcEllYBLME5Zk5pU= @@ -463,8 +438,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs= -golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w= golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/router/middleware/auth.go b/router/middleware/auth.go index 911905f81..6d96a2e36 100644 --- a/router/middleware/auth.go +++ b/router/middleware/auth.go @@ -14,6 +14,9 @@ import ( // ExternalAccessTokenHandlerFunc is a function that is called after validing access. type ExternalAccessTokenHandlerFunc func(user.ExternalAPIUser, http.ResponseWriter, *http.Request) +// UserAccessTokenHandlerFunc is a function that is called after validing user access. +type UserAccessTokenHandlerFunc func(user.User, http.ResponseWriter, *http.Request) + // RequireAdminAuth wraps a handler requiring HTTP basic auth for it using the given // the stream key as the password and and a hardcoded "admin" for username. func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc { @@ -94,7 +97,7 @@ func RequireExternalAPIAccessToken(scope string, handler ExternalAccessTokenHand // RequireUserAccessToken will validate a provided user's access token and make sure the associated user is enabled. // Not to be used for validating 3rd party access. -func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc { +func RequireUserAccessToken(handler UserAccessTokenHandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { accessToken := r.URL.Query().Get("accessToken") if accessToken == "" { @@ -119,7 +122,7 @@ func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc { return } - handler(w, r) + handler(*user, w, r) }) } diff --git a/router/middleware/headers.go b/router/middleware/headers.go index a10915515..4db63081d 100644 --- a/router/middleware/headers.go +++ b/router/middleware/headers.go @@ -21,7 +21,7 @@ func SetHeaders(w http.ResponseWriter) { } // Content security policy csp := []string{ - fmt.Sprintf("script-src 'self' %s 'sha256-rnxPrBaD0OuYxsCdrll4QJwtDLcBJqFh0u27CoX5jZ8=' 'sha256-PzXGlTLvNFZ7et6GkP2nD3XuSaAKQVBSYiHzU2ZKm8o=' 'sha256-/wqazZOqIpFSIrNVseblbKCXrezG73X7CMqRSTf+8zw=' 'sha256-jCj2f+ICtd8fvdb0ngc+Hkr/ZnZOMvNkikno/XR6VZs='", unsafeEval), + fmt.Sprintf("script-src 'self' %s 'sha256-B5bOgtE39ax4J6RqDE93TVYrJeLAdxDOJFtF3hoWYDw=' 'sha256-PzXGlTLvNFZ7et6GkP2nD3XuSaAKQVBSYiHzU2ZKm8o=' 'sha256-/wqazZOqIpFSIrNVseblbKCXrezG73X7CMqRSTf+8zw=' 'sha256-jCj2f+ICtd8fvdb0ngc+Hkr/ZnZOMvNkikno/XR6VZs='", unsafeEval), "worker-src 'self' blob:", // No single quotes around blob: } w.Header().Set("Content-Security-Policy", strings.Join(csp, "; ")) diff --git a/router/router.go b/router/router.go index 5c6f795fc..ee033611a 100644 --- a/router/router.go +++ b/router/router.go @@ -13,6 +13,7 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers/admin" + "github.com/owncast/owncast/controllers/auth/indieauth" "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/user" @@ -349,6 +350,15 @@ func Start() error { http.HandleFunc("/api/admin/config/notifications/browser", middleware.RequireAdminAuth(admin.SetBrowserNotificationConfiguration)) http.HandleFunc("/api/admin/config/notifications/twitter", middleware.RequireAdminAuth(admin.SetTwitterConfiguration)) + // Auth + + // 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) + // ActivityPub has its own router activitypub.Start(data.GetDatastore()) diff --git a/static/metadata.html.tmpl b/static/metadata.html.tmpl index e88da45a8..fb1ab7878 100644 --- a/static/metadata.html.tmpl +++ b/static/metadata.html.tmpl @@ -49,6 +49,8 @@ + + diff --git a/test/automated/api/integrations.test.js b/test/automated/api/integrations.test.js index d9d7d4a51..8501be001 100644 --- a/test/automated/api/integrations.test.js +++ b/test/automated/api/integrations.test.js @@ -113,7 +113,7 @@ test('send an external integration action using access token', async (done) => { const payload = { body: 'This is a test external action from the automated integration test', }; - const res = await request + await request .post('/api/integrations/chat/action') .set('Authorization', 'Bearer ' + accessToken) .send(payload) diff --git a/webroot/img/authenticated.svg b/webroot/img/authenticated.svg new file mode 100644 index 000000000..8437cd117 --- /dev/null +++ b/webroot/img/authenticated.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webroot/img/indieauth.png b/webroot/img/indieauth.png new file mode 100644 index 0000000000000000000000000000000000000000..bb7943b885ed37921a0f4acbd5eff580d154600d GIT binary patch literal 6668 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 DT4@Q4 literal 0 HcmV?d00001 diff --git a/webroot/img/user-settings.svg b/webroot/img/user-settings.svg new file mode 100644 index 000000000..868c7c003 --- /dev/null +++ b/webroot/img/user-settings.svg @@ -0,0 +1,2 @@ + + diff --git a/webroot/index.html b/webroot/index.html index 469c1818c..499dcc87e 100644 --- a/webroot/index.html +++ b/webroot/index.html @@ -1,42 +1,111 @@ + + Owncast + + + - - Owncast - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - + - - - + + + - + - - + + - - - - + + + + -