mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-10 01:00:05 +00:00
Add fine-grained permission control for extended channel permissions (#35)
This commit is contained in:
parent
4b9878fbf9
commit
a06d8fa1cd
4 changed files with 195 additions and 107 deletions
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
24
scopes.go
24
scopes.go
|
@ -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,
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -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…</p>
|
||||||
|
<b-form-checkbox-group
|
||||||
|
id="channelPermissions"
|
||||||
|
v-model="models.channelPermissions"
|
||||||
|
:options="extendedPermissions"
|
||||||
|
multiple
|
||||||
|
:select-size="extendedPermissions.length"
|
||||||
|
stacked
|
||||||
|
switches
|
||||||
|
/>
|
||||||
|
<p class="mt-3">
|
||||||
|
…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: [],
|
||||||
|
|
Loading…
Reference in a new issue