From a06d8fa1cda67e4000a34d4dba1d93fff3745c9e Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sun, 4 Dec 2022 17:14:15 +0100 Subject: [PATCH] Add fine-grained permission control for extended channel permissions (#35) --- configEditor_general.go | 31 ++--- internal/service/access/access.go | 46 ++++--- scopes.go | 24 ++-- src/generalConfig.vue | 201 +++++++++++++++++++++--------- 4 files changed, 195 insertions(+), 107 deletions(-) diff --git a/configEditor_general.go b/configEditor_general.go index d2f003a..42e8259 100644 --- a/configEditor_general.go +++ b/configEditor_general.go @@ -17,10 +17,10 @@ import ( type ( configEditorGeneralConfig struct { - BotEditors []string `json:"bot_editors"` - BotName *string `json:"bot_name,omitempty"` - Channels []string `json:"channels"` - ChannelHasScopes map[string]bool `json:"channel_has_scopes"` + BotEditors []string `json:"bot_editors"` + BotName *string `json:"bot_name,omitempty"` + Channels []string `json:"channels"` + ChannelScopes map[string][]string `json:"channel_scopes"` } ) @@ -135,10 +135,13 @@ func configEditorHandleGeneralAddAuthToken(w http.ResponseWriter, r *http.Reques func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, r *http.Request) { var out struct { - UpdateBotToken string `json:"update_bot_token"` - UpdateChannelScopes string `json:"update_channel_scopes"` + AvailableExtendedScopes map[string]string `json:"available_extended_scopes"` + UpdateBotToken string `json:"update_bot_token"` + UpdateChannelScopes string `json:"update_channel_scopes"` } + out.AvailableExtendedScopes = channelExtendedScopes + params := make(url.Values) params.Set("client_id", cfg.TwitchClient) params.Set("redirect_uri", strings.Join([]string{ @@ -155,7 +158,7 @@ func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, r *http.Request) { strings.TrimRight(cfg.BaseURL, "/"), "auth", "update-channel-scopes", }, "/")) - params.Set("scope", strings.Join(channelDefaultScopes, " ")) + params.Set("scope", "") out.UpdateChannelScopes = fmt.Sprintf("https://id.twitch.tv/oauth2/authorize?%s", params.Encode()) @@ -184,12 +187,12 @@ func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Req func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) { var ( - elevated = make(map[string]bool) - err error + channelScopes = make(map[string][]string) + err error ) for _, ch := range config.Channels { - if elevated[ch], err = accessService.HasPermissionsForChannel(ch, channelDefaultScopes...); err != nil { + if channelScopes[ch], err = accessService.GetChannelPermissions(ch); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -201,10 +204,10 @@ func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) { } if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{ - BotEditors: config.BotEditors, - BotName: uName, - Channels: config.Channels, - ChannelHasScopes: elevated, + BotEditors: config.BotEditors, + BotName: uName, + Channels: config.Channels, + ChannelScopes: channelScopes, }); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } diff --git a/internal/service/access/access.go b/internal/service/access/access.go index 2ba9a40..e98a1a0 100644 --- a/internal/service/access/access.go +++ b/internal/service/access/access.go @@ -44,6 +44,22 @@ func New(db database.Connector) (*Service, error) { ) } +func (s Service) GetChannelPermissions(channel string) ([]string, error) { + var ( + err error + perm extendedPermission + ) + + if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, errors.Wrap(err, "getting twitch credential from database") + } + + return strings.Split(perm.Scopes, " "), nil +} + func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) { var botAccessToken, botRefreshToken string @@ -98,20 +114,11 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t } func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (bool, error) { - var ( - err error - perm extendedPermission - ) - - if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, errors.Wrap(err, "getting twitch credential from database") + storedScopes, err := s.GetChannelPermissions(channel) + if err != nil { + return false, errors.Wrap(err, "getting channel scopes") } - storedScopes := strings.Split(perm.Scopes, " ") - for _, scope := range scopes { if str.StringInSlice(scope, storedScopes) { return true, nil @@ -122,20 +129,11 @@ func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (b } func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (bool, error) { - var ( - err error - perm extendedPermission - ) - - if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, errors.Wrap(err, "getting twitch credential from database") + storedScopes, err := s.GetChannelPermissions(channel) + if err != nil { + return false, errors.Wrap(err, "getting channel scopes") } - storedScopes := strings.Split(perm.Scopes, " ") - for _, scope := range scopes { if !str.StringInSlice(scope, storedScopes) { return false, nil diff --git a/scopes.go b/scopes.go index 1b4543b..1bca4d1 100644 --- a/scopes.go +++ b/scopes.go @@ -3,20 +3,26 @@ package main import "github.com/Luzifer/twitch-bot/v3/pkg/twitch" var ( - channelDefaultScopes = []string{ - twitch.ScopeChannelEditCommercial, - twitch.ScopeChannelManageBroadcast, - twitch.ScopeChannelReadRedemptions, - twitch.ScopeChannelManageRaids, + channelExtendedScopes = map[string]string{ + twitch.ScopeChannelEditCommercial: "run commercial", + twitch.ScopeChannelManageBroadcast: "modify category / title", + twitch.ScopeChannelManagePolls: "manage polls", + twitch.ScopeChannelManagePredictions: "manage predictions", + twitch.ScopeChannelManageRaids: "start raids", + twitch.ScopeChannelManageVIPS: "manage VIPs", + twitch.ScopeChannelReadRedemptions: "see channel-point redemptions", } - botDefaultScopes = append(channelDefaultScopes, - twitch.ScopeChatEdit, - twitch.ScopeChatRead, + botDefaultScopes = []string{ + // API Scopes twitch.ScopeModeratorManageAnnoucements, twitch.ScopeModeratorManageBannedUsers, twitch.ScopeModeratorManageChatMessages, twitch.ScopeModeratorManageChatSettings, + + // Chat Scopes + twitch.ScopeChatEdit, + twitch.ScopeChatRead, twitch.ScopeWhisperRead, - ) + } ) diff --git a/src/generalConfig.vue b/src/generalConfig.vue index 75eaf7f..c43f780 100644 --- a/src/generalConfig.vue +++ b/src/generalConfig.vue @@ -25,7 +25,7 @@ {{ channel }} - Channel cannot use features like channel-point redemptions. - See "Channel Permissions" for more info how to authorize. + Channel is missing {{ missingExtendedScopes(channel).length }} extended permissions. + Click pencil to change granted permissions. - - - + + + + + + + + @@ -281,52 +291,6 @@ - - - - - Channel Permissions - - - -

- In order to access non-public information as channel-point redemptions the bot needs additional permissions. The owner of the channel needs to grant those! -

-
    -
  • Copy the URL provided below
  • -
  • Pass the URL to the channel owner and tell them to open it with their personal account logged in
  • -
  • The bot will display a message containing the updated account
  • -
- - - - - - Copy - - - -
-
@@ -366,6 +330,70 @@ /> + + + + + +

The bot should be able to…

+ +

+ …on this channel. +

+
+ +

+ In order to access non-public information as channel-point redemptions or take actions limited to the channel owner the bot needs additional permissions. The owner of the channel needs to grant those! +

+
    +
  • Select permissions on the left side
  • +
  • Copy the URL provided below
  • +
  • Pass the URL to the channel owner and tell them to open it with their personal account logged in
  • +
  • The bot will display a message containing the updated account
  • +
+
+
+ + + + + + + + Copy + + + + + +
@@ -393,6 +421,22 @@ export default { return 'warning' }, + extendedPermissions() { + return Object.keys(this.authURLs.available_extended_scopes || {}) + .map(v => ({ text: this.authURLs.available_extended_scopes[v], value: v })) + .sort((a, b) => a.value.localeCompare(b.value)) + }, + + extendedPermissionsURL() { + if (!this.authURLs?.update_channel_scopes) { + return null + } + + const u = new URL(this.authURLs.update_channel_scopes) + u.searchParams.set('scope', this.models.channelPermissions.join(' ')) + return u.toString() + }, + sortedChannels() { return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase())) }, @@ -426,11 +470,13 @@ export default { addChannel: '', addEditor: '', apiToken: {}, + channelPermissions: [], }, modules: [], showAPITokenEditModal: false, + showPermissionEditModal: false, userProfiles: {}, } }, @@ -469,7 +515,7 @@ export default { btnField = 'botConnection' break case 'channelPermission': - prom = navigator.clipboard.writeText(this.authURLs.update_channel_scopes) + prom = navigator.clipboard.writeText(this.extendedPermissionsURL) btnField = 'channelPermission' break } @@ -488,6 +534,11 @@ export default { }) }, + editChannelPermissions(channel) { + this.models.channelPermissions = this.generalConfig.channel_scopes[channel] || [] + this.showPermissionEditModal = true + }, + fetchAPITokens() { this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true) return axios.get('config-editor/auth-tokens', this.$root.axiosOptions) @@ -541,6 +592,36 @@ export default { .catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err)) }, + hasAllExtendedScopes(channel) { + if (!this.generalConfig.channel_scopes[channel]) { + return false + } + + for (const scope in this.authURLs.available_extended_scopes) { + if (!this.generalConfig.channel_scopes[channel].includes(scope)) { + return false + } + } + + return true + }, + + missingExtendedScopes(channel) { + if (!this.generalConfig.channel_scopes[channel]) { + return Object.keys(this.authURLs.available_extended_scopes) + } + + const missing = [] + + for (const scope in this.authURLs.available_extended_scopes) { + if (!this.generalConfig.channel_scopes[channel].includes(scope)) { + missing.push(scope) + } + } + + return missing + }, + newAPIToken() { Vue.set(this.models, 'apiToken', { modules: [],