Add fine-grained permission control for extended channel permissions (#35)

This commit is contained in:
Knut Ahlers 2022-12-04 17:14:15 +01:00 committed by GitHub
parent 4b9878fbf9
commit a06d8fa1cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 195 additions and 107 deletions

View File

@ -17,10 +17,10 @@ import (
type ( type (
configEditorGeneralConfig struct { configEditorGeneralConfig struct {
BotEditors []string `json:"bot_editors"` BotEditors []string `json:"bot_editors"`
BotName *string `json:"bot_name,omitempty"` BotName *string `json:"bot_name,omitempty"`
Channels []string `json:"channels"` Channels []string `json:"channels"`
ChannelHasScopes map[string]bool `json:"channel_has_scopes"` 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) { func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, r *http.Request) {
var out struct { var out struct {
UpdateBotToken string `json:"update_bot_token"` AvailableExtendedScopes map[string]string `json:"available_extended_scopes"`
UpdateChannelScopes string `json:"update_channel_scopes"` UpdateBotToken string `json:"update_bot_token"`
UpdateChannelScopes string `json:"update_channel_scopes"`
} }
out.AvailableExtendedScopes = channelExtendedScopes
params := make(url.Values) params := make(url.Values)
params.Set("client_id", cfg.TwitchClient) params.Set("client_id", cfg.TwitchClient)
params.Set("redirect_uri", strings.Join([]string{ params.Set("redirect_uri", strings.Join([]string{
@ -155,7 +158,7 @@ func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, r *http.Request) {
strings.TrimRight(cfg.BaseURL, "/"), strings.TrimRight(cfg.BaseURL, "/"),
"auth", "update-channel-scopes", "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()) 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) { func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
var ( var (
elevated = make(map[string]bool) channelScopes = make(map[string][]string)
err error err error
) )
for _, ch := range config.Channels { 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) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -201,10 +204,10 @@ func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
} }
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{ if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
BotEditors: config.BotEditors, BotEditors: config.BotEditors,
BotName: uName, BotName: uName,
Channels: config.Channels, Channels: config.Channels,
ChannelHasScopes: elevated, ChannelScopes: channelScopes,
}); err != nil { }); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }

View File

@ -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) { func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
var botAccessToken, botRefreshToken string 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) { func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (bool, error) {
var ( storedScopes, err := s.GetChannelPermissions(channel)
err error if err != nil {
perm extendedPermission return false, errors.Wrap(err, "getting channel scopes")
)
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 := strings.Split(perm.Scopes, " ")
for _, scope := range scopes { for _, scope := range scopes {
if str.StringInSlice(scope, storedScopes) { if str.StringInSlice(scope, storedScopes) {
return true, nil 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) { func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (bool, error) {
var ( storedScopes, err := s.GetChannelPermissions(channel)
err error if err != nil {
perm extendedPermission return false, errors.Wrap(err, "getting channel scopes")
)
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 := strings.Split(perm.Scopes, " ")
for _, scope := range scopes { for _, scope := range scopes {
if !str.StringInSlice(scope, storedScopes) { if !str.StringInSlice(scope, storedScopes) {
return false, nil return false, nil

View File

@ -3,20 +3,26 @@ package main
import "github.com/Luzifer/twitch-bot/v3/pkg/twitch" import "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
var ( var (
channelDefaultScopes = []string{ channelExtendedScopes = map[string]string{
twitch.ScopeChannelEditCommercial, twitch.ScopeChannelEditCommercial: "run commercial",
twitch.ScopeChannelManageBroadcast, twitch.ScopeChannelManageBroadcast: "modify category / title",
twitch.ScopeChannelReadRedemptions, twitch.ScopeChannelManagePolls: "manage polls",
twitch.ScopeChannelManageRaids, twitch.ScopeChannelManagePredictions: "manage predictions",
twitch.ScopeChannelManageRaids: "start raids",
twitch.ScopeChannelManageVIPS: "manage VIPs",
twitch.ScopeChannelReadRedemptions: "see channel-point redemptions",
} }
botDefaultScopes = append(channelDefaultScopes, botDefaultScopes = []string{
twitch.ScopeChatEdit, // API Scopes
twitch.ScopeChatRead,
twitch.ScopeModeratorManageAnnoucements, twitch.ScopeModeratorManageAnnoucements,
twitch.ScopeModeratorManageBannedUsers, twitch.ScopeModeratorManageBannedUsers,
twitch.ScopeModeratorManageChatMessages, twitch.ScopeModeratorManageChatMessages,
twitch.ScopeModeratorManageChatSettings, twitch.ScopeModeratorManageChatSettings,
// Chat Scopes
twitch.ScopeChatEdit,
twitch.ScopeChatRead,
twitch.ScopeWhisperRead, twitch.ScopeWhisperRead,
) }
) )

View File

@ -25,7 +25,7 @@
{{ channel }} {{ channel }}
<span class="ml-auto mr-2"> <span class="ml-auto mr-2">
<font-awesome-icon <font-awesome-icon
v-if="!generalConfig.channel_has_scopes[channel]" v-if="!hasAllExtendedScopes(channel)"
:id="`channelPublicWarn${channel}`" :id="`channelPublicWarn${channel}`"
fixed-width fixed-width
class="ml-1 text-warning" class="ml-1 text-warning"
@ -35,20 +35,30 @@
:target="`channelPublicWarn${channel}`" :target="`channelPublicWarn${channel}`"
triggers="hover" triggers="hover"
> >
Channel cannot use features like channel-point redemptions. Channel is missing {{ missingExtendedScopes(channel).length }} extended permissions.
See "Channel Permissions" for more info how to authorize. Click pencil to change granted permissions.
</b-tooltip> </b-tooltip>
</span> </span>
<b-button <b-button-group size="sm">
size="sm" <b-button
variant="danger" variant="primary"
@click="removeChannel(channel)" @click="editChannelPermissions(channel)"
> >
<font-awesome-icon <font-awesome-icon
fixed-width fixed-width
:icon="['fas', 'minus']" :icon="['fas', 'pencil-alt']"
/> />
</b-button> </b-button>
<b-button
variant="danger"
@click="removeChannel(channel)"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'minus']"
/>
</b-button>
</b-button-group>
</b-list-group-item> </b-list-group-item>
<b-list-group-item> <b-list-group-item>
@ -281,52 +291,6 @@
</b-input-group> </b-input-group>
</b-card-body> </b-card-body>
</b-card> </b-card>
<b-card
no-body
class="mb-3"
>
<b-card-header>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'sign-in-alt']"
/>
Channel Permissions
</b-card-header>
<b-card-body>
<p>
In order to access non-public information as channel-point redemptions the bot needs additional permissions. The <strong>owner</strong> of the channel needs to grant those!
</p>
<ul>
<li>Copy the URL provided below</li>
<li>Pass the URL to the channel owner and tell them to open it with their personal account logged in</li>
<li>The bot will display a message containing the updated account</li>
</ul>
<b-input-group>
<b-form-input
placeholder="Loading..."
readonly
:value="authURLs.update_channel_scopes"
@focus="$event.target.select()"
/>
<b-input-group-append>
<b-button
:variant="copyButtonVariant.channelPermission"
@click="copyAuthURL('channelPermission')"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'clipboard']"
/>
Copy
</b-button>
</b-input-group-append>
</b-input-group>
</b-card-body>
</b-card>
</b-col> </b-col>
</b-row> </b-row>
@ -366,6 +330,70 @@
/> />
</b-form-group> </b-form-group>
</b-modal> </b-modal>
<!-- Channel Permission Editor -->
<b-modal
v-if="showPermissionEditModal"
hide-footer
size="lg"
title="Edit Permissions for Channel"
:visible="showPermissionEditModal"
@hidden="showPermissionEditModal=false"
>
<b-row>
<b-col>
<p>The bot should be able to&hellip;</p>
<b-form-checkbox-group
id="channelPermissions"
v-model="models.channelPermissions"
:options="extendedPermissions"
multiple
:select-size="extendedPermissions.length"
stacked
switches
/>
<p class="mt-3">
&hellip;on this channel.
</p>
</b-col>
<b-col>
<p>
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 <strong>owner</strong> of the channel needs to grant those!
</p>
<ul>
<li>Select permissions on the left side</li>
<li>Copy the URL provided below</li>
<li>Pass the URL to the channel owner and tell them to open it with their personal account logged in</li>
<li>The bot will display a message containing the updated account</li>
</ul>
</b-col>
</b-row>
<b-row>
<b-col>
<b-input-group>
<b-form-input
placeholder="Loading..."
readonly
:value="extendedPermissionsURL"
@focus="$event.target.select()"
/>
<b-input-group-append>
<b-button
:variant="copyButtonVariant.channelPermission"
@click="copyAuthURL('channelPermission')"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'clipboard']"
/>
Copy
</b-button>
</b-input-group-append>
</b-input-group>
</b-col>
</b-row>
</b-modal>
</div> </div>
</template> </template>
@ -393,6 +421,22 @@ export default {
return 'warning' 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() { sortedChannels() {
return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase())) return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()))
}, },
@ -426,11 +470,13 @@ export default {
addChannel: '', addChannel: '',
addEditor: '', addEditor: '',
apiToken: {}, apiToken: {},
channelPermissions: [],
}, },
modules: [], modules: [],
showAPITokenEditModal: false, showAPITokenEditModal: false,
showPermissionEditModal: false,
userProfiles: {}, userProfiles: {},
} }
}, },
@ -469,7 +515,7 @@ export default {
btnField = 'botConnection' btnField = 'botConnection'
break break
case 'channelPermission': case 'channelPermission':
prom = navigator.clipboard.writeText(this.authURLs.update_channel_scopes) prom = navigator.clipboard.writeText(this.extendedPermissionsURL)
btnField = 'channelPermission' btnField = 'channelPermission'
break break
} }
@ -488,6 +534,11 @@ export default {
}) })
}, },
editChannelPermissions(channel) {
this.models.channelPermissions = this.generalConfig.channel_scopes[channel] || []
this.showPermissionEditModal = true
},
fetchAPITokens() { fetchAPITokens() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true) this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/auth-tokens', this.$root.axiosOptions) 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)) .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() { newAPIToken() {
Vue.set(this.models, 'apiToken', { Vue.set(this.models, 'apiToken', {
modules: [], modules: [],