Compare commits

...

1 Commits

Author SHA1 Message Date
Gabe Kangas cff76707f0
WIP user repository 2 years ago
  1. 28
      .vscode/settings.json
  2. 6
      auth/persistence.go
  3. 4
      core/chat/chatclient.go
  4. 12
      core/chat/events.go
  5. 4
      core/chat/events/connectedClientInfo.go
  6. 8
      core/chat/events/events.go
  7. 3
      core/chat/persistence.go
  8. 13
      core/chat/server.go
  9. 3
      core/core.go
  10. 311
      core/user/externalAPIUser.go
  11. 473
      core/user/user.go
  12. 15
      core/webhooks/webhooks.go
  13. 19
      models/externalAPIUser.go
  14. 36
      models/user.go
  15. 6
      router/middleware/auth.go
  16. 25
      storage/externalAPIUser_test.go
  17. 770
      storage/userRepository.go

28
.vscode/settings.json vendored

@ -1,28 +0,0 @@ @@ -1,28 +0,0 @@
{
"cSpell.words": [
"Debugln",
"Errorln",
"Fediverse",
"Ffmpeg",
"ffmpegpath",
"ffmpg",
"geoip",
"gosec",
"mattn",
"Mbps",
"nolint",
"Owncast",
"ppid",
"preact",
"RTMP",
"rtmpserverport",
"sqlite",
"Tracef",
"Traceln",
"upgrader",
"Upgrader",
"videojs",
"Warnf",
"Warnln"
]
}

6
auth/persistence.go

@ -5,7 +5,7 @@ import ( @@ -5,7 +5,7 @@ import (
"strings"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/db"
)
@ -39,7 +39,7 @@ func AddAuth(userID, authToken string, authType Type) error { @@ -39,7 +39,7 @@ func AddAuth(userID, authToken string, authType Type) error {
// 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 {
func GetUserByAuth(authToken string, authType Type) *models.User {
u, err := _datastore.GetQueries().GetUserByAuth(context.Background(), db.GetUserByAuthParams{
Token: authToken,
Type: string(authType),
@ -53,7 +53,7 @@ func GetUserByAuth(authToken string, authType Type) *user.User { @@ -53,7 +53,7 @@ func GetUserByAuth(authToken string, authType Type) *user.User {
scopes = strings.Split(u.Scopes.String, ",")
}
return &user.User{
return &models.User{
ID: u.ID,
DisplayName: u.DisplayName,
DisplayColor: int(u.DisplayColor),

4
core/chat/chatclient.go

@ -13,8 +13,8 @@ import ( @@ -13,8 +13,8 @@ import (
"github.com/gorilla/websocket"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/geoip"
"github.com/owncast/owncast/models"
)
// Client represents a single chat client.
@ -23,7 +23,7 @@ type Client struct { @@ -23,7 +23,7 @@ type Client struct {
timeoutTimer *time.Timer
rateLimiter *rate.Limiter
conn *websocket.Conn
User *user.User `json:"user"`
User *models.User `json:"user"`
server *Server
Geo *geoip.GeoDetails `json:"geo"`
// Buffered channel of outbound messages.

12
core/chat/events.go

@ -9,8 +9,8 @@ import ( @@ -9,8 +9,8 @@ import (
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/storage"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
)
@ -26,6 +26,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { @@ -26,6 +26,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
// Check if name is on the blocklist
blocklist := data.GetForbiddenUsernameList()
userRepository := storage.GetUserRepository()
// Names have a max length
proposedUsername = utils.MakeSafeStringOfLength(proposedUsername, config.MaxChatDisplayNameLength)
@ -47,7 +48,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { @@ -47,7 +48,7 @@ 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 {
if available, err := userRepository.IsDisplayNameAvailable(proposedUsername); err != nil {
log.Errorln("error checking if name is available", err)
return
} else if !available {
@ -60,11 +61,11 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { @@ -60,11 +61,11 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
return
}
savedUser := user.GetUserByToken(eventData.client.accessToken)
savedUser := userRepository.GetUserByToken(eventData.client.accessToken)
oldName := savedUser.DisplayName
// Save the new name
if err := user.ChangeUsername(eventData.client.User.ID, proposedUsername); err != nil {
if err := userRepository.ChangeUsername(eventData.client.User.ID, proposedUsername); err != nil {
log.Errorln("error changing username", err)
}
@ -108,9 +109,10 @@ func (s *Server) userColorChanged(eventData chatClientEvent) { @@ -108,9 +109,10 @@ func (s *Server) userColorChanged(eventData chatClientEvent) {
log.Errorln("invalid color requested when changing user display color")
return
}
userRepository := storage.GetUserRepository()
// Save the new color
if err := user.ChangeUserColor(eventData.client.User.ID, receivedEvent.NewColor); err != nil {
if err := userRepository.ChangeUserColor(eventData.client.User.ID, receivedEvent.NewColor); err != nil {
log.Errorln("error changing user display color", err)
}

4
core/chat/events/connectedClientInfo.go

@ -1,9 +1,9 @@ @@ -1,9 +1,9 @@
package events
import "github.com/owncast/owncast/core/user"
import "github.com/owncast/owncast/models"
// ConnectedClientInfo represents the information about a connected client.
type ConnectedClientInfo struct {
Event
User *user.User `json:"user"`
User *models.User `json:"user"`
}

8
core/chat/events/events.go

@ -7,13 +7,13 @@ import ( @@ -7,13 +7,13 @@ import (
"time"
"github.com/microcosm-cc/bluemonday"
"github.com/owncast/owncast/models"
"github.com/teris-io/shortid"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
"mvdan.cc/xurls"
"github.com/owncast/owncast/core/user"
log "github.com/sirupsen/logrus"
)
@ -35,9 +35,9 @@ type Event struct { @@ -35,9 +35,9 @@ type Event struct {
// UserEvent is an event with an associated user.
type UserEvent struct {
User *user.User `json:"user"`
HiddenAt *time.Time `json:"hiddenAt,omitempty"`
ClientID uint `json:"clientId,omitempty"`
User *models.User `json:"user"`
HiddenAt *time.Time `json:"hiddenAt,omitempty"`
ClientID uint `json:"clientId,omitempty"`
}
// MessageEvent is an event that has a message body.

3
core/chat/persistence.go

@ -8,7 +8,6 @@ import ( @@ -8,7 +8,6 @@ import (
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
)
@ -104,7 +103,7 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent { @@ -104,7 +103,7 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent {
isBot := (row.userType != nil && *row.userType == "API")
scopeSlice := strings.Split(scopes, ",")
u := user.User{
u := models.User{
ID: *row.userID,
DisplayName: displayName,
DisplayColor: displayColor,

13
core/chat/server.go

@ -14,9 +14,10 @@ import ( @@ -14,9 +14,10 @@ import (
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/geoip"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/storage"
"github.com/owncast/owncast/utils"
)
@ -80,7 +81,7 @@ func (s *Server) Run() { @@ -80,7 +81,7 @@ func (s *Server) Run() {
}
// Addclient registers new connection as a User.
func (s *Server) Addclient(conn *websocket.Conn, user *user.User, accessToken string, userAgent string, ipAddress string) *Client {
func (s *Server) Addclient(conn *websocket.Conn, user *models.User, accessToken string, userAgent string, ipAddress string) *Client {
client := &Client{
server: s,
conn: conn,
@ -199,8 +200,10 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request) @@ -199,8 +200,10 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request)
return
}
userRepository := storage.GetUserRepository()
// A user is required to use the websocket
user := user.GetUserByToken(accessToken)
user := userRepository.GetUserByToken(accessToken)
if user == nil {
// Send error that registration is required
_ = conn.WriteJSON(events.EventPayload{
@ -295,8 +298,10 @@ func SendConnectedClientInfoToUser(userID string) error { @@ -295,8 +298,10 @@ func SendConnectedClientInfoToUser(userID string) error {
return err
}
userRepository := storage.GetUserRepository()
// Get an updated reference to the user.
user := user.GetUserByID(userID)
user := userRepository.GetUserByID(userID)
if user == nil {
return fmt.Errorf("user not found")
}

3
core/core.go

@ -13,7 +13,6 @@ import ( @@ -13,7 +13,6 @@ import (
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/rtmp"
"github.com/owncast/owncast/core/transcoder"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/notifications"
@ -56,7 +55,7 @@ func Start() error { @@ -56,7 +55,7 @@ func Start() error {
log.Errorln("storage error", err)
}
user.SetupUsers()
// user.SetupUsers()
auth.Setup(data.GetDatastore())
fileWriter.SetupFileWriterReceiverService(&handler)

311
core/user/externalAPIUser.go

@ -1,311 +0,0 @@ @@ -1,311 +0,0 @@
package user
import (
"context"
"database/sql"
"strings"
"time"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid"
)
// ExternalAPIUser represents a single 3rd party integration that uses an access token.
// This struct mostly matches the User struct so they can be used interchangeably.
type ExternalAPIUser struct {
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
ID string `json:"id"`
AccessToken string `json:"accessToken"`
DisplayName string `json:"displayName"`
Type string `json:"type,omitempty"` // Should be API
Scopes []string `json:"scopes"`
DisplayColor int `json:"displayColor"`
IsBot bool `json:"isBot"`
}
const (
// ScopeCanSendChatMessages will allow sending chat messages as itself.
ScopeCanSendChatMessages = "CAN_SEND_MESSAGES"
// ScopeCanSendSystemMessages will allow sending chat messages as the system.
ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES"
// ScopeHasAdminAccess will allow performing administrative actions on the server.
ScopeHasAdminAccess = "HAS_ADMIN_ACCESS"
)
// For a scope to be seen as "valid" it must live in this slice.
var validAccessTokenScopes = []string{
ScopeCanSendChatMessages,
ScopeCanSendSystemMessages,
ScopeHasAdminAccess,
}
// InsertExternalAPIUser will add a new API user to the database.
func InsertExternalAPIUser(token string, name string, color int, scopes []string) error {
log.Traceln("Adding new API user")
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
scopesString := strings.Join(scopes, ",")
id := shortid.MustGenerate()
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
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, name, color, scopesString, "API", name); err != nil {
return err
}
if err = tx.Commit(); err != nil {
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
}
// DeleteExternalAPIUser will delete a token from the database.
func DeleteExternalAPIUser(token string) error {
log.Traceln("Deleting access token")
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
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(token)
if err != nil {
return err
}
if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 {
tx.Rollback() //nolint
return errors.New(token + " not found")
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
// GetExternalAPIUserForAccessTokenAndScope will determine if a specific token has access to perform a scoped action.
func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*ExternalAPIUser, error) {
// This will split the scopes from comma separated to individual rows
// 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,
scopes,
display_name,
display_color,
created_at,
last_used
FROM
user_access_tokens
INNER JOIN (
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 AS u
UNION ALL
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,
display_name,
display_color,
created_at,
last_used,
disabled_at,
scopes,
scope
FROM
split
WHERE
scope <> ''
) ON user_access_tokens.user_id = id
WHERE
disabled_at IS NULL
AND token = ?
AND scope = ?;`
row := _datastore.DB.QueryRow(query, token, scope)
integration, err := makeExternalAPIUserFromRow(row)
return integration, err
}
// GetIntegrationNameForAccessToken will return the integration name associated with a specific access token.
func GetIntegrationNameForAccessToken(token string) *string {
name, err := _datastore.GetQueries().GetUserDisplayNameByToken(context.Background(), token)
if err != nil {
return nil
}
return &name
}
// GetExternalAPIUser will return all API users with access tokens.
func GetExternalAPIUser() ([]ExternalAPIUser, error) { //nolint
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 {
return []ExternalAPIUser{}, err
}
defer rows.Close()
integrations, err := makeExternalAPIUsersFromRows(rows)
return integrations, err
}
// SetExternalAPIUserAccessTokenAsUsed will update the last used timestamp for a token.
func SetExternalAPIUserAccessTokenAsUsed(token string) error {
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
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
}
defer stmt.Close()
if _, err := stmt.Exec(token); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) {
var id string
var displayName string
var displayColor int
var scopes string
var createdAt time.Time
var lastUsedAt *time.Time
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
}
integration := ExternalAPIUser{
ID: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
Scopes: strings.Split(scopes, ","),
LastUsedAt: lastUsedAt,
}
return &integration, nil
}
func makeExternalAPIUsersFromRows(rows *sql.Rows) ([]ExternalAPIUser, error) {
integrations := make([]ExternalAPIUser, 0)
for rows.Next() {
var id string
var accessToken string
var displayName string
var displayColor int
var scopes string
var createdAt time.Time
var lastUsedAt *time.Time
err := rows.Scan(&id, &accessToken, &displayName, &displayColor, &scopes, &createdAt, &lastUsedAt)
if err != nil {
log.Errorln(err)
return nil, err
}
integration := ExternalAPIUser{
ID: id,
AccessToken: accessToken,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
Scopes: strings.Split(scopes, ","),
LastUsedAt: lastUsedAt,
IsBot: true,
}
integrations = append(integrations, integration)
}
return integrations, nil
}
// HasValidScopes will verify that all the scopes provided are valid.
func HasValidScopes(scopes []string) bool {
for _, scope := range scopes {
_, foundInSlice := utils.FindInSlice(validAccessTokenScopes, scope)
if !foundInSlice {
return false
}
}
return true
}

473
core/user/user.go

@ -1,473 +0,0 @@ @@ -1,473 +0,0 @@
package user
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
"github.com/owncast/owncast/config"
"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"
)
var _datastore *data.Datastore
const (
moderatorScopeKey = "MODERATOR"
minSuggestedUsernamePoolLength = 10
)
// User represents a single chat user.
type User struct {
CreatedAt time.Time `json:"createdAt"`
DisabledAt *time.Time `json:"disabledAt,omitempty"`
NameChangedAt *time.Time `json:"nameChangedAt,omitempty"`
AuthenticatedAt *time.Time `json:"-"`
ID string `json:"id"`
DisplayName string `json:"displayName"`
PreviousNames []string `json:"previousNames"`
Scopes []string `json:"scopes,omitempty"`
DisplayColor int `json:"displayColor"`
IsBot bool `json:"isBot"`
Authenticated bool `json:"authenticated"`
}
// IsEnabled will return if this single user is enabled.
func (u *User) IsEnabled() bool {
return u.DisabledAt == nil
}
// IsModerator will return if the user has moderation privileges.
func (u *User) IsModerator() bool {
_, hasModerationScope := utils.FindInSlice(u.Scopes, moderatorScopeKey)
return hasModerationScope
}
// SetupUsers will perform the initial initialization of the user package.
func SetupUsers() {
_datastore = data.GetDatastore()
}
func generateDisplayName() string {
suggestedUsernamesList := data.GetSuggestedUsernamesList()
if len(suggestedUsernamesList) >= minSuggestedUsernamePoolLength {
index := utils.RandomIndex(len(suggestedUsernamesList))
return suggestedUsernamesList[index]
} else {
return utils.GeneratePhrase()
}
}
// CreateAnonymousUser will create a new anonymous user with the provided display name.
func CreateAnonymousUser(displayName string) (*User, string, error) {
// Try to assign a name that was requested.
if displayName != "" {
// If name isn't available then generate a random one.
if available, _ := IsDisplayNameAvailable(displayName); !available {
displayName = generateDisplayName()
}
} else {
displayName = generateDisplayName()
}
displayColor := utils.GenerateRandomDisplayColor(config.MaxUserColor)
id := shortid.MustGenerate()
user := &User{
ID: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: time.Now(),
}
// Create new user.
if err := create(user); err != nil {
return nil, "", err
}
// 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) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
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")
}
return nil
}
// ChangeUserColor will change the user associated to userID from one display name to another.
func ChangeUserColor(userID string, color int) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
if err := _datastore.GetQueries().ChangeDisplayColor(context.Background(), db.ChangeDisplayColorParams{
DisplayColor: int32(color),
ID: userID,
}); err != nil {
return errors.Wrap(err, "unable to change display color")
}
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 {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
}
defer func() {
_ = tx.Rollback()
}()
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.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt)
if err != nil {
log.Errorln("error creating new user", err)
return err
}
return tx.Commit()
}
// SetEnabled will set the enabled status of a single user by ID.
func SetEnabled(userID string, enabled bool) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint
var stmt *sql.Stmt
if !enabled {
stmt, err = tx.Prepare("UPDATE users SET disabled_at=DATETIME('now', 'localtime') WHERE id IS ?")
} else {
stmt, err = tx.Prepare("UPDATE users SET disabled_at=null WHERE id IS ?")
}
if err != nil {
return err
}
defer stmt.Close()
if _, err := stmt.Exec(userID); err != nil {
return err
}
return tx.Commit()
}
// GetUserByToken will return a user by an access token.
func GetUserByToken(token string) *User {
u, err := _datastore.GetQueries().GetUserByAccessToken(context.Background(), token)
if err != nil {
return nil
}
var scopes []string
if u.Scopes.Valid {
scopes = strings.Split(u.Scopes.String, ",")
}
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.
func SetModerator(userID string, isModerator bool) error {
if isModerator {
return addScopeToUser(userID, moderatorScopeKey)
}
return removeScopeFromUser(userID, moderatorScopeKey)
}
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
scopesSlice := utils.StringMapKeys(scopes)
return setScopesOnUser(userID, scopesSlice)
}
func removeScopeFromUser(userID string, scope string) error {
u := GetUserByID(userID)
scopesString := u.Scopes
scopes := utils.StringSliceToMap(scopesString)
delete(scopes, scope)
scopesSlice := utils.StringMapKeys(scopes)
return setScopesOnUser(userID, scopesSlice)
}
func setScopesOnUser(userID string, scopes []string) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint
scopesSliceString := strings.TrimSpace(strings.Join(scopes, ","))
stmt, err := tx.Prepare("UPDATE users SET scopes=? WHERE id IS ?")
if err != nil {
return err
}
defer stmt.Close()
var val *string
if scopesSliceString == "" {
val = nil
} else {
val = &scopesSliceString
}
if _, err := stmt.Exec(val, userID); err != nil {
return err
}
return tx.Commit()
}
// GetUserByID will return a user by a user ID.
func GetUserByID(id string) *User {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE id = ?"
row := _datastore.DB.QueryRow(query, id)
if row == nil {
log.Errorln(row)
return nil
}
return getUserFromRow(row)
}
// GetDisabledUsers will return back all the currently disabled users that are not API users.
func GetDisabledUsers() []*User {
query := "SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE disabled_at IS NOT NULL AND type IS NOT 'API'"
rows, err := _datastore.DB.Query(query)
if err != nil {
log.Errorln(err)
return nil
}
defer rows.Close()
users := getUsersFromRows(rows)
sort.Slice(users, func(i, j int) bool {
return users[i].DisabledAt.Before(*users[j].DisabledAt)
})
return users
}
// GetModeratorUsers will return a list of users with moderator access.
func GetModeratorUsers() []*User {
query := `SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM (
WITH RECURSIVE split(id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope, rest) AS (
SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, '', scopes || ',' FROM users
UNION ALL
SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at,
substr(rest, 0, instr(rest, ',')),
substr(rest, instr(rest, ',')+1)
FROM split
WHERE rest <> '')
SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope
FROM split
WHERE scope <> ''
ORDER BY created_at
) AS token WHERE token.scope = ?`
rows, err := _datastore.DB.Query(query, moderatorScopeKey)
if err != nil {
log.Errorln(err)
return nil
}
defer rows.Close()
users := getUsersFromRows(rows)
return users
}
func getUsersFromRows(rows *sql.Rows) []*User {
users := make([]*User, 0)
for rows.Next() {
var id string
var displayName string
var displayColor int
var createdAt time.Time
var disabledAt *time.Time
var previousUsernames string
var userNameChangedAt *time.Time
var scopesString *string
if err := rows.Scan(&id, &displayName, &scopesString, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil {
log.Errorln("error creating collection of users from results", err)
return nil
}
var scopes []string
if scopesString != nil {
scopes = strings.Split(*scopesString, ",")
}
user := &User{
ID: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
DisabledAt: disabledAt,
PreviousNames: strings.Split(previousUsernames, ","),
NameChangedAt: userNameChangedAt,
Scopes: scopes,
}
users = append(users, user)
}
sort.Slice(users, func(i, j int) bool {
return users[i].CreatedAt.Before(users[j].CreatedAt)
})
return users
}
func getUserFromRow(row *sql.Row) *User {
var id string
var displayName string
var displayColor int
var createdAt time.Time
var disabledAt *time.Time
var previousUsernames string
var userNameChangedAt *time.Time
var scopesString *string
if err := row.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt, &scopesString); err != nil {
return nil
}
var scopes []string
if scopesString != nil {
scopes = strings.Split(*scopesString, ",")
}
return &User{
ID: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
DisabledAt: disabledAt,
PreviousNames: strings.Split(previousUsernames, ","),
NameChangedAt: userNameChangedAt,
Scopes: scopes,
}
}

15
core/webhooks/webhooks.go

@ -5,7 +5,6 @@ import ( @@ -5,7 +5,6 @@ import (
"time"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models"
)
@ -17,13 +16,13 @@ type WebhookEvent struct { @@ -17,13 +16,13 @@ type WebhookEvent struct {
// WebhookChatMessage represents a single chat message sent as a webhook payload.
type WebhookChatMessage struct {
User *user.User `json:"user,omitempty"`
Timestamp *time.Time `json:"timestamp,omitempty"`
Body string `json:"body,omitempty"`
RawBody string `json:"rawBody,omitempty"`
ID string `json:"id,omitempty"`
ClientID uint `json:"clientId,omitempty"`
Visible bool `json:"visible"`
User *models.User `json:"user,omitempty"`
Timestamp *time.Time `json:"timestamp,omitempty"`
Body string `json:"body,omitempty"`
RawBody string `json:"rawBody,omitempty"`
ID string `json:"id,omitempty"`
ClientID uint `json:"clientId,omitempty"`
Visible bool `json:"visible"`
}
// SendEventToWebhooks will send a single webhook event to all webhook destinations.

19
models/externalAPIUser.go

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
package models
import (
"time"
)
// ExternalAPIUser represents a single 3rd party integration that uses an access token.
// This struct mostly matches the User struct so they can be used interchangeably.
type ExternalAPIUser struct {
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
ID string `json:"id"`
AccessToken string `json:"accessToken"`
DisplayName string `json:"displayName"`
Type string `json:"type,omitempty"` // Should be API
Scopes []string `json:"scopes"`
DisplayColor int `json:"displayColor"`
IsBot bool `json:"isBot"`
}

36
models/user.go

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
package models
import (
"time"
"github.com/owncast/owncast/utils"
)
const (
moderatorScopeKey = "MODERATOR"
)
type User struct {
CreatedAt time.Time `json:"createdAt"`
DisabledAt *time.Time `json:"disabledAt,omitempty"`
NameChangedAt *time.Time `json:"nameChangedAt,omitempty"`
AuthenticatedAt *time.Time `json:"-"`
ID string `json:"id"`
DisplayName string `json:"displayName"`
PreviousNames []string `json:"previousNames"`
Scopes []string `json:"scopes,omitempty"`
DisplayColor int `json:"displayColor"`
IsBot bool `json:"isBot"`
Authenticated bool `json:"authenticated"`
}
// IsEnabled will return if this single user is enabled.
func (u *User) IsEnabled() bool {
return u.DisabledAt == nil
}
// IsModerator will return if the user has moderation privileges.
func (u *User) IsModerator() bool {
_, hasModerationScope := utils.FindInSlice(u.Scopes, moderatorScopeKey)
return hasModerationScope
}

6
router/middleware/auth.go

@ -6,16 +6,16 @@ import ( @@ -6,16 +6,16 @@ import (
"strings"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
)
// ExternalAccessTokenHandlerFunc is a function that is called after validing access.
type ExternalAccessTokenHandlerFunc func(user.ExternalAPIUser, http.ResponseWriter, *http.Request)
type ExternalAccessTokenHandlerFunc func(models.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)
type UserAccessTokenHandlerFunc func(models.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.

25
core/user/externalAPIUser_test.go → storage/externalAPIUser_test.go

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
package user
package storage
import (
"testing"
@ -11,24 +11,27 @@ const ( @@ -11,24 +11,27 @@ const (
token = "test-token-123"
)
var testScopes = []string{"test-scope"}
var (
testScopes = []string{"test-scope"}
userRepository UserRepository
)
func TestMain(m *testing.M) {
if err := data.SetupPersistence(":memory:"); err != nil {
panic(err)
}
SetupUsers()
userRepository = NewUserRepository(data.GetDatastore())
m.Run()
}
func TestCreateExternalAPIUser(t *testing.T) {
if err := InsertExternalAPIUser(token, tokenName, 0, testScopes); err != nil {
if err := userRepository.InsertExternalAPIUser(token, tokenName, 0, testScopes); err != nil {
t.Fatal(err)
}
user := GetUserByToken(token)
user := userRepository.GetUserByToken(token)
if user == nil {
t.Fatal("api user not found after creating")
}
@ -43,13 +46,13 @@ func TestCreateExternalAPIUser(t *testing.T) { @@ -43,13 +46,13 @@ func TestCreateExternalAPIUser(t *testing.T) {
}
func TestDeleteExternalAPIUser(t *testing.T) {
if err := DeleteExternalAPIUser(token); err != nil {
if err := userRepository.DeleteExternalAPIUser(token); err != nil {
t.Fatal(err)
}
}
func TestVerifyTokenDisabled(t *testing.T) {
users, err := GetExternalAPIUser()
users, err := userRepository.GetExternalAPIUser()
if err != nil {
t.Fatal(err)
}
@ -60,7 +63,7 @@ func TestVerifyTokenDisabled(t *testing.T) { @@ -60,7 +63,7 @@ func TestVerifyTokenDisabled(t *testing.T) {
}
func TestVerifyGetUserTokenDisabled(t *testing.T) {
user := GetUserByToken(token)
user := userRepository.GetUserByToken(token)
if user == nil {
t.Fatal("user not returned in GetUserByToken after disabling")
}
@ -71,7 +74,7 @@ func TestVerifyGetUserTokenDisabled(t *testing.T) { @@ -71,7 +74,7 @@ func TestVerifyGetUserTokenDisabled(t *testing.T) {
}
func TestVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled(t *testing.T) {
user, _ := GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0])
user, _ := userRepository.GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0])
if user != nil {
t.Fatal("user returned in GetExternalAPIUserForAccessTokenAndScope after disabling")
@ -79,13 +82,13 @@ func TestVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled(t *testing. @@ -79,13 +82,13 @@ func TestVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled(t *testing.
}
func TestCreateAdditionalAPIUser(t *testing.T) {
if err := InsertExternalAPIUser("ignore-me", "token-to-be-ignored", 0, testScopes); err != nil {
if err := userRepository.InsertExternalAPIUser("ignore-me", "token-to-be-ignored", 0, testScopes); err != nil {
t.Fatal(err)
}
}
func TestAgainVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled(t *testing.T) {
user, _ := GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0])
user, _ := userRepository.GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0])
if user != nil {
t.Fatal("user returned in TestAgainVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled after disabling")

770
storage/userRepository.go

@ -0,0 +1,770 @@ @@ -0,0 +1,770 @@
package storage
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
"github.com/teris-io/shortid"
log "github.com/sirupsen/logrus"
)
type UserRepository interface {
ChangeUserColor(userID string, color int) error
ChangeUsername(userID string, username string) error
CreateAnonymousUser(displayName string) (*models.User, string, error)
DeleteExternalAPIUser(token string) error
GetDisabledUsers() []*models.User
GetExternalAPIUser() ([]models.ExternalAPIUser, error)
GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*models.ExternalAPIUser, error)
GetModeratorUsers() []*models.User
GetUserByID(id string) *models.User
GetUserByToken(token string) *models.User
InsertExternalAPIUser(token string, name string, color int, scopes []string) error
IsDisplayNameAvailable(displayName string) (bool, error)
SetAccessTokenToOwner(token, userID string) error
SetEnabled(userID string, enabled bool) error
SetModerator(userID string, isModerator bool) error
SetUserAsAuthenticated(userID string) error
HasValidScopes(scopes []string) bool
}
type SqlUserRepository struct {
datastore *data.Datastore
}
// NOTE: This is temporary during the transition period.
var temporaryGlobalInstance UserRepository
// GetUserRepository will return the user repository.
func GetUserRepository() UserRepository {
if temporaryGlobalInstance == nil {
i := NewUserRepository(data.GetDatastore())
temporaryGlobalInstance = i
}
return temporaryGlobalInstance
}
const (
// ScopeCanSendChatMessages will allow sending chat messages as itself.
ScopeCanSendChatMessages = "CAN_SEND_MESSAGES"
// ScopeCanSendSystemMessages will allow sending chat messages as the system.
ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES"
// ScopeHasAdminAccess will allow performing administrative actions on the server.
ScopeHasAdminAccess = "HAS_ADMIN_ACCESS"
moderatorScopeKey = "MODERATOR"
minSuggestedUsernamePoolLength = 10
)
// User represents a single chat user.
// SetupUsers will perform the initial initialization of the user package.
func NewUserRepository(datastore *data.Datastore) UserRepository {
r := &SqlUserRepository{
datastore: datastore,
}
return r
}
func (u *SqlUserRepository) generateDisplayName() string {
suggestedUsernamesList := data.GetSuggestedUsernamesList()
if len(suggestedUsernamesList) >= minSuggestedUsernamePoolLength {
index := utils.RandomIndex(len(suggestedUsernamesList))
return suggestedUsernamesList[index]
} else {
return utils.GeneratePhrase()
}
}
// CreateAnonymousUser will create a new anonymous user with the provided display name.
func (r *SqlUserRepository) CreateAnonymousUser(displayName string) (*models.User, string, error) {
// Try to assign a name that was requested.
if displayName != "" {
// If name isn't available then generate a random one.
if available, _ := r.IsDisplayNameAvailable(displayName); !available {
displayName = r.generateDisplayName()
}
} else {
displayName = r.generateDisplayName()
}
displayColor := utils.GenerateRandomDisplayColor(config.MaxUserColor)
id := shortid.MustGenerate()
user := &models.User{
ID: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: time.Now(),
}
// Create new user.
if err := r.create(user); err != nil {
return nil, "", err
}
// 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 := r.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 (r *SqlUserRepository) IsDisplayNameAvailable(displayName string) (bool, error) {
if available, err := r.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 (r *SqlUserRepository) ChangeUsername(userID string, username string) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
if err := r.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")
}
return nil
}
// ChangeUserColor will change the user associated to userID from one display name to another.
func (r *SqlUserRepository) ChangeUserColor(userID string, color int) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
if err := r.datastore.GetQueries().ChangeDisplayColor(context.Background(), db.ChangeDisplayColorParams{
DisplayColor: int32(color),
ID: userID,
}); err != nil {
return errors.Wrap(err, "unable to change display color")
}
return nil
}
func (r *SqlUserRepository) addAccessTokenForUser(accessToken, userID string) error {
return r.datastore.GetQueries().AddAccessTokenForUser(context.Background(), db.AddAccessTokenForUserParams{
Token: accessToken,
UserID: userID,
})
}
func (r *SqlUserRepository) create(user *models.User) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
log.Debugln(err)
}
defer func() {
_ = tx.Rollback()
}()
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.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt)
if err != nil {
log.Errorln("error creating new user", err)
return err
}
return tx.Commit()
}
// SetEnabled will set the enabled status of a single user by ID.
func (r *SqlUserRepository) SetEnabled(userID string, enabled bool) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint
var stmt *sql.Stmt
if !enabled {
stmt, err = tx.Prepare("UPDATE users SET disabled_at=DATETIME('now', 'localtime') WHERE id IS ?")
} else {
stmt, err = tx.Prepare("UPDATE users SET disabled_at=null WHERE id IS ?")
}
if err != nil {
return err
}
defer stmt.Close()
if _, err := stmt.Exec(userID); err != nil {
return err
}
return tx.Commit()
}
// GetUserByToken will return a user by an access token.
func (r *SqlUserRepository) GetUserByToken(token string) *models.User {
u, err := r.datastore.GetQueries().GetUserByAccessToken(context.Background(), token)
if err != nil {
return nil
}
var scopes []string
if u.Scopes.Valid {
scopes = strings.Split(u.Scopes.String, ",")
}
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 &models.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 (r *SqlUserRepository) SetAccessTokenToOwner(token, userID string) error {
return r.datastore.GetQueries().SetAccessTokenToOwner(context.Background(), db.SetAccessTokenToOwnerParams{
UserID: userID,
Token: token,
})
}
// SetUserAsAuthenticated will mark that a user has been authenticated
// in some way.
func (r *SqlUserRepository) SetUserAsAuthenticated(userID string) error {
return errors.Wrap(r.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.
func (r *SqlUserRepository) SetModerator(userID string, isModerator bool) error {
if isModerator {
return r.addScopeToUser(userID, moderatorScopeKey)
}
return r.removeScopeFromUser(userID, moderatorScopeKey)
}
func (r *SqlUserRepository) addScopeToUser(userID string, scope string) error {
u := r.GetUserByID(userID)
if u == nil {
return errors.New("user not found when modifying scope")
}
scopesString := u.Scopes
scopes := utils.StringSliceToMap(scopesString)
scopes[scope] = true
scopesSlice := utils.StringMapKeys(scopes)
return r.setScopesOnUser(userID, scopesSlice)
}
func (r *SqlUserRepository) removeScopeFromUser(userID string, scope string) error {
u := r.GetUserByID(userID)
scopesString := u.Scopes
scopes := utils.StringSliceToMap(scopesString)
delete(scopes, scope)
scopesSlice := utils.StringMapKeys(scopes)
return r.setScopesOnUser(userID, scopesSlice)
}
func (r *SqlUserRepository) setScopesOnUser(userID string, scopes []string) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint
scopesSliceString := strings.TrimSpace(strings.Join(scopes, ","))
stmt, err := tx.Prepare("UPDATE users SET scopes=? WHERE id IS ?")
if err != nil {
return err
}
defer stmt.Close()
var val *string
if scopesSliceString == "" {
val = nil
} else {
val = &scopesSliceString
}
if _, err := stmt.Exec(val, userID); err != nil {
return err
}
return tx.Commit()
}
// GetUserByID will return a user by a user ID.
func (r *SqlUserRepository) GetUserByID(id string) *models.User {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE id = ?"
row := r.datastore.DB.QueryRow(query, id)
if row == nil {
log.Errorln(row)
return nil
}
return r.getUserFromRow(row)
}
// GetDisabledUsers will return back all the currently disabled users that are not API users.
func (r *SqlUserRepository) GetDisabledUsers() []*models.User {
query := "SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE disabled_at IS NOT NULL AND type IS NOT 'API'"
rows, err := r.datastore.DB.Query(query)
if err != nil {
log.Errorln(err)
return nil
}
defer rows.Close()
users := r.getUsersFromRows(rows)
sort.Slice(users, func(i, j int) bool {
return users[i].DisabledAt.Before(*users[j].DisabledAt)
})
return users
}
// GetModeratorUsers will return a list of users with moderator access.
func (r *SqlUserRepository) GetModeratorUsers() []*models.User {
query := `SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM (
WITH RECURSIVE split(id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope, rest) AS (
SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, '', scopes || ',' FROM users
UNION ALL
SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at,
substr(rest, 0, instr(rest, ',')),
substr(rest, instr(rest, ',')+1)
FROM split
WHERE rest <> '')
SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope
FROM split
WHERE scope <> ''
ORDER BY created_at
) AS token WHERE token.scope = ?`
rows, err := r.datastore.DB.Query(query, moderatorScopeKey)
if err != nil {
log.Errorln(err)
return nil
}
defer rows.Close()
users := r.getUsersFromRows(rows)
return users
}
func (r *SqlUserRepository) getUsersFromRows(rows *sql.Rows) []*models.User {
users := make([]*models.User, 0)
for rows.Next() {
var id string
var displayName string
var displayColor int
var createdAt time.Time
var disabledAt *time.Time
var previousUsernames string
var userNameChangedAt *time.Time
var scopesString *string
if err := rows.Scan(&id, &displayName, &scopesString, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil {
log.Errorln("error creating collection of users from results", err)
return nil
}
var scopes []string
if scopesString != nil {
scopes = strings.Split(*scopesString, ",")
}
user := &models.User{
ID: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
DisabledAt: disabledAt,
PreviousNames: strings.Split(previousUsernames, ","),
NameChangedAt: userNameChangedAt,
Scopes: scopes,
}
users = append(users, user)
}
sort.Slice(users, func(i, j int) bool {
return users[i].CreatedAt.Before(users[j].CreatedAt)
})
return users
}
func (r *SqlUserRepository) getUserFromRow(row *sql.Row) *models.User {
var id string
var displayName string
var displayColor int
var createdAt time.Time
var disabledAt *time.Time
var previousUsernames string
var userNameChangedAt *time.Time
var scopesString *string
if err := row.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt, &scopesString); err != nil {
return nil
}
var scopes []string
if scopesString != nil {
scopes = strings.Split(*scopesString, ",")
}
return &models.User{
ID: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
DisabledAt: disabledAt,
PreviousNames: strings.Split(previousUsernames, ","),
NameChangedAt: userNameChangedAt,
Scopes: scopes,
}
}
// InsertExternalAPIUser will add a new API user to the database.
func (r *SqlUserRepository) InsertExternalAPIUser(token string, name string, color int, scopes []string) error {
log.Traceln("Adding new API user")
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
scopesString := strings.Join(scopes, ",")
id := shortid.MustGenerate()
tx, err := r.datastore.DB.Begin()
if err != nil {
return err
}
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, name, color, scopesString, "API", name); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
if err := r.addAccessTokenForUser(token, id); err != nil {
return errors.Wrap(err, "unable to save access token for new external api user")
}
return nil
}
// DeleteExternalAPIUser will delete a token from the database.
func (r *SqlUserRepository) DeleteExternalAPIUser(token string) error {
log.Traceln("Deleting access token")
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
return err
}
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(token)
if err != nil {
return err
}
if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 {
tx.Rollback() //nolint
return errors.New(token + " not found")
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
// GetExternalAPIUserForAccessTokenAndScope will determine if a specific token has access to perform a scoped action.
func (r *SqlUserRepository) GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*models.ExternalAPIUser, error) {
// This will split the scopes from comma separated to individual rows
// 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,
scopes,
display_name,
display_color,
created_at,
last_used
FROM
user_access_tokens
INNER JOIN (
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 AS u
UNION ALL
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,
display_name,
display_color,
created_at,
last_used,
disabled_at,
scopes,
scope
FROM
split
WHERE
scope <> ''
) ON user_access_tokens.user_id = id
WHERE
disabled_at IS NULL
AND token = ?
AND scope = ?;`
row := r.datastore.DB.QueryRow(query, token, scope)
integration, err := r.makeExternalAPIUserFromRow(row)
return integration, err
}
// GetIntegrationNameForAccessToken will return the integration name associated with a specific access token.
func (r *SqlUserRepository) GetIntegrationNameForAccessToken(token string) *string {
name, err := r.datastore.GetQueries().GetUserDisplayNameByToken(context.Background(), token)
if err != nil {
return nil
}
return &name
}
// GetExternalAPIUser will return all API users with access tokens.
func (r *SqlUserRepository) GetExternalAPIUser() ([]models.ExternalAPIUser, error) { //nolint
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 := r.datastore.DB.Query(query)
if err != nil {
return []models.ExternalAPIUser{}, err
}
defer rows.Close()
integrations, err := r.makeExternalAPIUsersFromRows(rows)
return integrations, err
}
// SetExternalAPIUserAccessTokenAsUsed will update the last used timestamp for a token.
func (r *SqlUserRepository) SetExternalAPIUserAccessTokenAsUsed(token string) error {
tx, err := r.datastore.DB.Begin()
if err != nil {
return err
}
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
}
defer stmt.Close()
if _, err := stmt.Exec(token); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
func (r *SqlUserRepository) makeExternalAPIUserFromRow(row *sql.Row) (*models.ExternalAPIUser, error) {
var id string
var displayName string
var displayColor int
var scopes string
var createdAt time.Time
var lastUsedAt *time.Time
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
}
integration := models.ExternalAPIUser{
ID: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
Scopes: strings.Split(scopes, ","),
LastUsedAt: lastUsedAt,
}
return &integration, nil
}
func (r *SqlUserRepository) makeExternalAPIUsersFromRows(rows *sql.Rows) ([]models.ExternalAPIUser, error) {
integrations := make([]models.ExternalAPIUser, 0)
for rows.Next() {
var id string
var accessToken string
var displayName string
var displayColor int
var scopes string
var createdAt time.Time
var lastUsedAt *time.Time
err := rows.Scan(&id, &accessToken, &displayName, &displayColor, &scopes, &createdAt, &lastUsedAt)
if err != nil {
log.Errorln(err)
return nil, err
}
integration := models.ExternalAPIUser{
ID: id,
AccessToken: accessToken,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
Scopes: strings.Split(scopes, ","),
LastUsedAt: lastUsedAt,
IsBot: true,
}
integrations = append(integrations, integration)
}
return integrations, nil
}
// HasValidScopes will verify that all the scopes provided are valid.
func (r *SqlUserRepository) HasValidScopes(scopes []string) bool {
// For a scope to be seen as "valid" it must live in this slice.
validAccessTokenScopes := []string{
ScopeCanSendChatMessages,
ScopeCanSendSystemMessages,
ScopeHasAdminAccess,
}
for _, scope := range scopes {
_, foundInSlice := utils.FindInSlice(validAccessTokenScopes, scope)
if !foundInSlice {
return false
}
}
return true
}
Loading…
Cancel
Save