[core] Implement dynamic token update and broadcaster permissions (#13)

This commit is contained in:
Knut Ahlers 2021-12-31 13:42:37 +01:00 committed by GitHub
parent 77334aca94
commit 437ef14fb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1196 additions and 261 deletions

150
auth.go Normal file
View file

@ -0,0 +1,150 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gofrs/uuid/v3"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/Luzifer/twitch-bot/plugins"
"github.com/Luzifer/twitch-bot/twitch"
)
var instanceState = uuid.Must(uuid.NewV4()).String()
func init() {
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
{
Description: "Updates the bots token for connection to chat and API",
HandlerFunc: handleAuthUpdateBotToken,
Method: http.MethodGet,
Module: "auth",
Name: "Update bot token",
Path: "/update-bot-token",
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
},
{
Description: "Updates scope configuration for EventSub subscription of a channel",
HandlerFunc: handleAuthUpdateChannelGrant,
Method: http.MethodGet,
Module: "auth",
Name: "Update channel scopes",
Path: "/update-channel-scopes",
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
},
} {
if err := registerRoute(rd); err != nil {
log.WithError(err).Fatal("Unable to register auth routes")
}
}
}
func handleAuthUpdateBotToken(w http.ResponseWriter, r *http.Request) {
var (
code = r.FormValue("code")
state = r.FormValue("state")
)
if state != instanceState {
http.Error(w, "invalid state, please start again", http.StatusBadRequest)
return
}
params := make(url.Values)
params.Set("client_id", cfg.TwitchClient)
params.Set("client_secret", cfg.TwitchClientSecret)
params.Set("code", code)
params.Set("grant_type", "authorization_code")
params.Set("redirect_uri", strings.Join([]string{
strings.TrimRight(cfg.BaseURL, "/"),
"auth", "update-bot-token",
}, "/"))
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("https://id.twitch.tv/oauth2/token?%s", params.Encode()), nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, errors.Wrap(err, "getting access token").Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var rData twitch.OAuthTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil {
http.Error(w, errors.Wrap(err, "decoding access token").Error(), http.StatusInternalServerError)
return
}
botUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUsername()
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return
}
if err = store.UpdateBotToken(rData.AccessToken, rData.RefreshToken); err != nil {
http.Error(w, errors.Wrap(err, "storing access token").Error(), http.StatusInternalServerError)
return
}
twitchClient.UpdateToken(rData.AccessToken, rData.RefreshToken)
if err = store.SetGrantedScopes(botUser, rData.Scope, true); err != nil {
http.Error(w, errors.Wrap(err, "storing access scopes").Error(), http.StatusInternalServerError)
return
}
http.Error(w, fmt.Sprintf("Authorization as %q complete, you can now close this window.", botUser), http.StatusOK)
}
func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) {
var (
code = r.FormValue("code")
state = r.FormValue("state")
)
if state != instanceState {
http.Error(w, "invalid state, please start again", http.StatusBadRequest)
return
}
params := make(url.Values)
params.Set("client_id", cfg.TwitchClient)
params.Set("client_secret", cfg.TwitchClientSecret)
params.Set("code", code)
params.Set("grant_type", "authorization_code")
params.Set("redirect_uri", strings.Join([]string{
strings.TrimRight(cfg.BaseURL, "/"),
"auth", "update-channel-scopes",
}, "/"))
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("https://id.twitch.tv/oauth2/token?%s", params.Encode()), nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, errors.Wrap(err, "getting access token").Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var rData twitch.OAuthTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil {
http.Error(w, errors.Wrap(err, "decoding access token").Error(), http.StatusInternalServerError)
return
}
grantUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUsername()
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return
}
if err = store.SetGrantedScopes(grantUser, rData.Scope, false); err != nil {
http.Error(w, errors.Wrap(err, "storing access scopes").Error(), http.StatusInternalServerError)
return
}
http.Error(w, fmt.Sprintf("Scopes for %q updated, you can now close this window.", grantUser), http.StatusOK)
}

View file

@ -15,7 +15,7 @@ func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error
return "", nil, errors.New("no authorization provided")
}
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token)
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
user, err := tc.GetAuthorizedUsername()
return user, tc, errors.Wrap(err, "getting authorized user")

View file

@ -2,7 +2,10 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gofrs/uuid/v3"
"github.com/gorilla/mux"
@ -15,7 +18,9 @@ import (
type (
configEditorGeneralConfig struct {
BotEditors []string `json:"bot_editors"`
BotName string `json:"bot_name"`
Channels []string `json:"channels"`
ChannelHasScopes map[string]bool `json:"channel_has_scopes"`
}
)
@ -79,6 +84,16 @@ func registerEditorGeneralConfigRoutes() {
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
},
{
Description: "Get Bot-Auth URLs for updating bot token and channel scopes",
HandlerFunc: configEditorHandleGeneralAuthURLs,
Method: http.MethodGet,
Module: "config-editor",
Name: "Get Bot-Auth-URLs",
Path: "/auth-urls",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
} {
if err := registerRoute(rd); err != nil {
log.WithError(err).Fatal("Unable to register config editor route")
@ -118,6 +133,37 @@ 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"`
}
params := make(url.Values)
params.Set("client_id", cfg.TwitchClient)
params.Set("redirect_uri", strings.Join([]string{
strings.TrimRight(cfg.BaseURL, "/"),
"auth", "update-bot-token",
}, "/"))
params.Set("response_type", "code")
params.Set("scope", strings.Join(botDefaultScopes, " "))
params.Set("state", instanceState)
out.UpdateBotToken = fmt.Sprintf("https://id.twitch.tv/oauth2/authorize?%s", params.Encode())
params.Set("redirect_uri", strings.Join([]string{
strings.TrimRight(cfg.BaseURL, "/"),
"auth", "update-channel-scopes",
}, "/"))
params.Set("scope", strings.Join(channelDefaultScopes, " "))
out.UpdateChannelScopes = fmt.Sprintf("https://id.twitch.tv/oauth2/authorize?%s", params.Encode())
if err := json.NewEncoder(w).Encode(out); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
if err != nil {
@ -137,9 +183,23 @@ func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Req
}
func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
elevated := make(map[string]bool)
for _, ch := range config.Channels {
elevated[ch] = store.UserHasGrantedScopes(ch, channelDefaultScopes...)
}
uName, err := twitchClient.GetAuthorizedUsername()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
BotEditors: config.BotEditors,
BotName: uName,
Channels: config.Channels,
ChannelHasScopes: elevated,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}

129
crypt/crypt.go Normal file
View file

@ -0,0 +1,129 @@
package crypt
import (
"reflect"
"strings"
"time"
"github.com/pkg/errors"
"github.com/Luzifer/go-openssl/v4"
)
const encryptedValuePrefix = "enc:"
type encryptAction uint8
const (
handleTagsDecrypt encryptAction = iota
handleTagsEncrypt
)
var osslClient = openssl.New()
// DecryptFields iterates through the given struct and decrypts all
// fields marked with a struct tag of `encrypt:"true"`. The fields
// are directly manipulated and the value is replaced.
//
// The input object needs to be a pointer to a struct!
func DecryptFields(obj interface{}, passphrase string) error {
return handleEncryptedTags(obj, passphrase, handleTagsDecrypt)
}
// EncryptFields iterates through the given struct and encrypts all
// fields marked with a struct tag of `encrypt:"true"`. The fields
// are directly manipulated and the value is replaced.
//
// The input object needs to be a pointer to a struct!
func EncryptFields(obj interface{}, passphrase string) error {
return handleEncryptedTags(obj, passphrase, handleTagsEncrypt)
}
//nolint:gocognit,gocyclo // Reflect loop, cannot reduce complexity
func handleEncryptedTags(obj interface{}, passphrase string, action encryptAction) error {
// Check we got a pointer and can manipulate the struct
if kind := reflect.TypeOf(obj).Kind(); kind != reflect.Ptr {
return errors.Errorf("expected pointer to struct, got %s", kind)
}
// Check we got a struct in the pointer
if kind := reflect.ValueOf(obj).Elem().Kind(); kind != reflect.Struct {
return errors.Errorf("expected pointer to struct, got pointer to %s", kind)
}
// Iterate over fields to find encrypted fields to manipulate
st := reflect.ValueOf(obj).Elem()
for i := 0; i < st.NumField(); i++ {
v := st.Field(i)
t := st.Type().Field(i)
if t.PkgPath != "" && !t.Anonymous {
// Caught us an non-exported field, ignore that one
continue
}
hasEncryption := t.Tag.Get("encrypt") == "true"
switch t.Type.Kind() {
// Type: Pointer - Recurse if not nil and struct inside
case reflect.Ptr:
if !v.IsNil() && v.Elem().Kind() == reflect.Struct && t.Type != reflect.TypeOf(&time.Time{}) {
if err := handleEncryptedTags(v.Interface(), passphrase, action); err != nil {
return err
}
}
// Type: String - Replace value if required
case reflect.String:
if hasEncryption {
newValue, err := manipulateValue(v.String(), passphrase, action)
if err != nil {
return errors.Wrap(err, "manipulating value")
}
v.SetString(newValue)
}
// Type: Struct - Welcome to recursion
case reflect.Struct:
if t.Type != reflect.TypeOf(time.Time{}) {
if err := handleEncryptedTags(v.Addr().Interface(), passphrase, action); err != nil {
return err
}
}
// We don't support anything else. Yet.
default:
if hasEncryption {
return errors.Errorf("unsupported field type for encyption: %s", t.Type.Kind())
}
}
}
return nil
}
func manipulateValue(val, passphrase string, action encryptAction) (string, error) {
switch action {
case handleTagsDecrypt:
if !strings.HasPrefix(val, encryptedValuePrefix) {
// This is not an encrypted string: Return the value itself for
// working with legacy values in storage
return val, nil
}
d, err := osslClient.DecryptBytes(passphrase, []byte(strings.TrimPrefix(val, encryptedValuePrefix)), openssl.PBKDF2SHA256)
return string(d), errors.Wrap(err, "decrypting value")
case handleTagsEncrypt:
if strings.HasPrefix(val, encryptedValuePrefix) {
// This is an encrypted string: shouldn't happen but whatever
return val, nil
}
e, err := osslClient.EncryptBytes(passphrase, []byte(val), openssl.PBKDF2SHA256)
return encryptedValuePrefix + string(e), errors.Wrap(err, "encrypting value")
default:
return "", errors.New("invalid action")
}
}

3
go.mod
View file

@ -3,6 +3,7 @@ module github.com/Luzifer/twitch-bot
go 1.17
require (
github.com/Luzifer/go-openssl/v4 v4.1.0
github.com/Luzifer/go_helpers/v2 v2.12.2
github.com/Luzifer/korvike/functions v0.6.1
github.com/Luzifer/rconfig/v2 v2.3.0
@ -15,6 +16,7 @@ require (
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.8.1
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
gopkg.in/src-d/go-git.v4 v4.13.1
gopkg.in/yaml.v2 v2.4.0
)
@ -42,7 +44,6 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/src-d/gcfg v1.4.0 // indirect
github.com/xanzy/ssh-agent v0.2.1 // indirect
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
golang.org/x/text v0.3.6 // indirect

2
go.sum
View file

@ -3,6 +3,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Luzifer/go-openssl/v4 v4.1.0 h1:8qi3Z6f8Aflwub/Cs4FVSmKUEg/lC8GlODbR2TyZ+nM=
github.com/Luzifer/go-openssl/v4 v4.1.0/go.mod h1:3i1T3Pe6eQK19d86WhuQzjLyMwBaNmGmt3ZceWpWVa4=
github.com/Luzifer/go_helpers/v2 v2.12.2 h1:B6ekpZ2d938tRaFXQtLZZdBSjVi2jabt2uzLGj3bYc8=
github.com/Luzifer/go_helpers/v2 v2.12.2/go.mod h1:Jp1wZqtbEFNrLlJW7noomOF7fLRW36k+ELITPDK4SHc=
github.com/Luzifer/korvike/functions v0.6.1 h1:OGDaEciVzQh0NUMUxcEK1/vmHLIn4lmneoU/iuKc8YI=

7
irc.go
View file

@ -65,9 +65,14 @@ func newIRCHandler() (*ircHandler, error) {
return nil, errors.Wrap(err, "connect to IRC server")
}
token, err := twitchClient.GetToken()
if err != nil {
return nil, errors.Wrap(err, "getting auth token")
}
h.c = irc.NewClient(conn, irc.ClientConfig{
Nick: username,
Pass: strings.Join([]string{"oauth", cfg.TwitchToken}, ":"),
Pass: strings.Join([]string{"oauth", token}, ":"),
User: username,
Name: username,
Handler: h,

79
main.go
View file

@ -5,6 +5,7 @@ import (
"context"
"fmt"
"io/ioutil"
"math"
"net"
"net/http"
"net/url"
@ -25,7 +26,13 @@ import (
"github.com/Luzifer/twitch-bot/twitch"
)
const ircReconnectDelay = 100 * time.Millisecond
const (
ircReconnectDelay = 100 * time.Millisecond
initialIRCRetryBackoff = 500 * time.Millisecond
ircRetryBackoffMultiplier = 1.5
maxIRCRetryBackoff = time.Minute
)
var (
cfg = struct {
@ -36,9 +43,10 @@ var (
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
PluginDir string `flag:"plugin-dir" default:"/usr/lib/twitch-bot" description:"Where to find and load plugins"`
StorageFile string `flag:"storage-file" default:"./storage.json.gz" description:"Where to store the data"`
StorageEncryptionPass string `flag:"storage-encryption-pass" default:"" description:"Passphrase to encrypt secrets inside storage (defaults to twitch-client:twitch-client-secret)"`
TwitchClient string `flag:"twitch-client" default:"" description:"Client ID to act as"`
TwitchClientSecret string `flag:"twitch-client-secret" default:"" description:"Secret for the Client ID"`
TwitchToken string `flag:"twitch-token" default:"" description:"OAuth token valid for client"`
TwitchToken string `flag:"twitch-token" default:"" description:"OAuth token valid for client (fallback if no token was set in interface)"`
ValidateConfig bool `flag:"validate-config,v" default:"false" description:"Loads the config, logs any errors and quits with status 0 on success"`
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
}{}
@ -85,6 +93,14 @@ func init() {
} else {
log.SetLevel(l)
}
if cfg.StorageEncryptionPass == "" {
log.Warn("No storage encryption passphrase was set, falling back to client-id:client-secret")
cfg.StorageEncryptionPass = strings.Join([]string{
cfg.TwitchClient,
cfg.TwitchClientSecret,
}, ":")
}
}
func handleSubCommand(args []string) {
@ -139,21 +155,38 @@ func handleSubCommand(args []string) {
func main() {
var err error
if err = store.Load(); err != nil {
log.WithError(err).Fatal("Unable to load storage file")
}
cronService = cron.New()
twitchClient = twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, cfg.TwitchToken)
twitchClient = twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, store.GetBotToken(cfg.TwitchToken), store.BotRefreshToken)
twitchClient.SetTokenUpdateHook(func(at, rt string) error {
if err := store.UpdateBotToken(at, rt); err != nil {
return errors.Wrap(err, "updating store")
}
// Misuse the config reload hook to let the frontend reload its state
configReloadHooksLock.RLock()
defer configReloadHooksLock.RUnlock()
for _, fn := range configReloadHooks {
fn()
}
return nil
})
twitchWatch := newTwitchWatcher()
cronService.AddFunc("@every 10s", twitchWatch.Check) // Query may run that often as the twitchClient has an internal cache
// Query may run that often as the twitchClient has an internal
// cache but shouldn't run more often as EventSub subscriptions
// are retried on error each time
cronService.AddFunc("@every 30s", twitchWatch.Check)
router.Use(corsMiddleware)
router.HandleFunc("/openapi.html", handleSwaggerHTML)
router.HandleFunc("/openapi.json", handleSwaggerRequest)
router.HandleFunc("/selfcheck", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(runID)) })
if err = store.Load(); err != nil {
log.WithError(err).Fatal("Unable to load storage file")
}
if err = initCorePlugins(); err != nil {
log.WithError(err).Fatal("Unable to load core plugins")
}
@ -196,6 +229,7 @@ func main() {
var (
ircDisconnected = make(chan struct{}, 1)
ircRetryBackoff = initialIRCRetryBackoff
autoMessageTicker = time.NewTicker(time.Second)
)
@ -222,13 +256,16 @@ func main() {
twitchEventSubClient, err = twitch.NewEventSubClient(twitchClient, strings.Join([]string{
strings.TrimRight(cfg.BaseURL, "/"),
"eventsub",
handle,
}, "/"), secret, handle)
if err != nil {
log.WithError(err).Fatal("Unable to create eventsub client")
}
if err := twitchWatch.registerGlobalHooks(); err != nil {
log.WithError(err).Fatal("Unable to register global eventsub hooks")
}
router.HandleFunc("/eventsub/{keyhandle}", twitchEventSubClient.HandleEventsubPush).Methods(http.MethodPost)
}
}
@ -250,9 +287,17 @@ func main() {
}
if ircHdl, err = newIRCHandler(); err != nil {
log.WithError(err).Fatal("Unable to create IRC client")
log.WithError(err).Error("Unable to connect to IRC")
go func() {
time.Sleep(ircRetryBackoff)
ircRetryBackoff = time.Duration(math.Min(float64(maxIRCRetryBackoff), float64(ircRetryBackoff)*ircRetryBackoffMultiplier))
ircDisconnected <- struct{}{}
}()
continue
}
ircRetryBackoff = initialIRCRetryBackoff // Successfully created, reset backoff
go func() {
if err := ircHdl.Run(); err != nil {
log.WithError(err).Error("IRC run exited unexpectedly")
@ -366,17 +411,19 @@ func startCheck() error {
errs = append(errs, "No Twitch-ClientId given")
}
if cfg.TwitchToken == "" {
errs = append(errs, "Twitch-Token is unset")
if cfg.TwitchClientSecret == "" {
errs = append(errs, "No Twitch-ClientSecret given")
}
if len(errs) > 0 {
fmt.Println(`
You've not provided a Twitch-ClientId and/or a Twitch-Token.
You've not provided a Twitch-ClientId and/or a Twitch-ClientSecret.
These parameters are required and you need to provide them. In case
you need help with obtaining those credentials please visit the
following website:
These parameters are required and you need to provide them.
The Twitch Token can be set through the web-interface. In case you
want to set it through parameters and need help with obtaining it,
please visit the following website:
https://luzifer.github.io/twitch-bot/

View file

@ -75,7 +75,7 @@ func TestAllowExecuteDisableOnOffline(t *testing.T) {
r := &Rule{DisableOnOffline: testPtrBool(true)}
// Fake cache entries to prevent calling the real Twitch API
r.twitchClient = twitch.New("", "", "")
r.twitchClient = twitch.New("", "", "", "")
r.twitchClient.APICache().Set([]string{"hasLiveStream", "channel1"}, time.Minute, true)
r.twitchClient.APICache().Set([]string{"hasLiveStream", "channel2"}, time.Minute, false)

19
scopes.go Normal file
View file

@ -0,0 +1,19 @@
package main
import "github.com/Luzifer/twitch-bot/twitch"
var (
botDefaultScopes = []string{
twitch.ScopeChatRead,
twitch.ScopeChatEdit,
twitch.ScopeWhisperRead,
twitch.ScopeWhisperEdit,
twitch.ScopeChannelModerate,
twitch.ScopeChannelManageBroadcast,
twitch.ScopeChannelEditCommercial,
}
channelDefaultScopes = []string{
twitch.ScopeChannelReadRedemptions,
}
)

View file

@ -60,28 +60,57 @@
>
<font-awesome-icon
fixed-width
class="mr-1 text-warning"
class="text-warning"
:icon="['fas', 'spinner']"
pulse
/>
</b-nav-text>
<b-nav-text>
<b-nav-text
class="ml-2"
>
<template
v-for="check in status.checks"
>
<font-awesome-icon
:id="`statusCheck${check.name}`"
:key="check.key"
fixed-width
:class="{ 'text-danger': !check.success, 'text-success': check.success }"
:icon="['fas', 'question-circle']"
/>
<b-tooltip
:key="check.key"
:target="`statusCheck${check.name}`"
triggers="hover"
>
{{ check.description }}
</b-tooltip>
</template>
</b-nav-text>
<b-nav-text class="ml-2">
<font-awesome-icon
v-if="configNotifySocketConnected"
v-b-tooltip.hover
id="socketConnectionStatus"
fixed-width
class="mr-1 text-success"
:icon="['fas', 'ethernet']"
title="Connected to Bot"
/>
<font-awesome-icon
v-else
v-b-tooltip.hover
id="socketConnectionStatus"
fixed-width
class="mr-1 text-danger"
:icon="['fas', 'ethernet']"
title="Disconnected to Bot"
/>
<b-tooltip
target="socketConnectionStatus"
triggers="hover"
>
<span v-if="configNotifySocketConnected">Connected to Bot</span>
<span v-else>Disconnected from Bot</span>
</b-tooltip>
</b-nav-text>
</b-navbar-nav>
</b-collapse>
@ -162,6 +191,8 @@
<script>
import * as constants from './const.js'
import axios from 'axios'
export default {
computed: {
authURL() {
@ -200,10 +231,19 @@ export default {
configNotifySocketConnected: false,
error: null,
loadingData: false,
status: {},
}
},
methods: {
fetchStatus() {
return axios.get('status/status.json?fail-status=200')
.then(resp => {
this.status = resp.data
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
handleFetchError(err) {
switch (err.response.status) {
case 403:
@ -256,6 +296,9 @@ export default {
if (this.isAuthenticated) {
this.openConfigNotifySocket()
}
window.setInterval(() => this.fetchStatus(), 10000)
this.fetchStatus()
},
name: 'TwitchBotEditorApp',

View file

@ -2,7 +2,6 @@
<div>
<b-row>
<b-col>
<b-card-group columns>
<b-card no-body>
<b-card-header>
<font-awesome-icon
@ -25,6 +24,20 @@
:icon="['fas', 'hashtag']"
/>
{{ channel }}
<font-awesome-icon
v-if="!generalConfig.channel_has_scopes[channel]"
:id="`channelPublicWarn${channel}`"
fixed-width
class="ml-1 text-warning"
:icon="['fas', 'exclamation-triangle']"
/>
<b-tooltip
:target="`channelPublicWarn${channel}`"
triggers="hover"
>
Channel cannot use features like channel-point redemptions.
See "Channel Permissions" for more info how to authorize.
</b-tooltip>
</span>
<b-button
size="sm"
@ -62,8 +75,12 @@
</b-list-group-item>
</b-list-group>
</b-card>
<b-card no-body>
</b-col>
<b-col>
<b-card
no-body
class="mb-3"
>
<b-card-header>
<font-awesome-icon
fixed-width
@ -120,7 +137,9 @@
</b-list-group>
</b-card>
<b-card no-body>
<b-card
no-body
>
<b-card-header
class="d-flex align-items-center align-middle"
>
@ -180,7 +199,113 @@
</b-list-group-item>
</b-list-group>
</b-card>
</b-card-group>
</b-col>
<b-col>
<b-card
no-body
class="mb-3"
:border-variant="botConnectionCardVariant"
>
<b-card-header
class="d-flex align-items-center align-middle"
:header-bg-variant="botConnectionCardVariant"
>
<span class="mr-auto">
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'sign-in-alt']"
/>
Bot Connection
</span>
<code id="botUserName">{{ generalConfig.bot_name }}</code>
<b-tooltip
target="botUserName"
triggers="hover"
>
Twitch Login-Name of the bot user currently authorized
</b-tooltip>
</b-card-header>
<b-card-body>
<p>
Here you can manage your bots auth-token: it's required to communicate with Twitch Chat and APIs. This will override the token you might have provided when starting the bot and will be automatically renewed as long as you don't change your password or revoke the apps permission on your bot account.
</p>
<ul>
<li>Copy the URL provided below</li>
<li>Open an inkognito tab or different browser you are not logged into Twitch or are logged in with your bot account</li>
<li>Open the copied URL, sign in with the bot account and accept the permissions</li>
<li>The bot will display a message containing the authorized account. If this account is wrong, just start over, the token will be overwritten.</li>
</ul>
<b-input-group>
<b-form-input
placeholder="Loading..."
readonly
:value="authURLs.update_bot_token"
@focus="$event.target.select()"
/>
<b-input-group-append>
<b-button
:variant="copyButtonVariant.botConnection"
@click="copyAuthURL('botConnection')"
>
<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-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-row>
@ -240,6 +365,13 @@ export default {
]
},
botConnectionCardVariant() {
if (this.$parent.status.overall_status_success) {
return 'secondary'
}
return 'warning'
},
sortedChannels() {
return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()))
},
@ -261,6 +393,12 @@ export default {
data() {
return {
apiTokens: {},
authURLs: {},
copyButtonVariant: {
botConnection: 'primary',
channelPermission: 'primary',
},
createdAPIToken: null,
generalConfig: {},
models: {
@ -292,6 +430,35 @@ export default {
this.updateGeneralConfig()
},
copyAuthURL(type) {
let prom = null
let btnField = null
switch (type) {
case 'botConnection':
prom = navigator.clipboard.writeText(this.authURLs.update_bot_token)
btnField = 'botConnection'
break
case 'channelPermission':
prom = navigator.clipboard.writeText(this.authURLs.update_channel_scopes)
btnField = 'channelPermission'
break
}
return prom
.then(() => {
this.copyButtonVariant[btnField] = 'success'
})
.catch(() => {
this.copyButtonVariant[btnField] = 'danger'
})
.finally(() => {
window.setTimeout(() => {
this.copyButtonVariant[btnField] = 'primary'
}, 2000)
})
},
fetchAPITokens() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/auth-tokens', this.$root.axiosOptions)
@ -301,6 +468,15 @@ export default {
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
fetchAuthURLs() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/auth-urls', this.$root.axiosOptions)
.then(resp => {
this.authURLs = resp.data
})
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
fetchGeneralConfig() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/general', this.$root.axiosOptions)
@ -397,6 +573,7 @@ export default {
Promise.all([
this.fetchGeneralConfig(),
this.fetchAPITokens(),
this.fetchAuthURLs(),
]).then(() => {
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, false)
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false)
@ -406,6 +583,7 @@ export default {
Promise.all([
this.fetchGeneralConfig(),
this.fetchAPITokens(),
this.fetchAuthURLs(),
this.fetchModules(),
]).then(() => this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false))
},

View file

@ -63,8 +63,8 @@ func handleStatusRequest(w http.ResponseWriter, r *http.Request) {
for _, chk := range []statusResponseCheck{
{
Name: "IRC connection alive",
Description: fmt.Sprintf("IRC connection received a message in last %s", statusIRCMessageReceivedTimeout),
Name: "Chat connection alive",
Description: fmt.Sprintf("Chat connection received a message in last %s", statusIRCMessageReceivedTimeout),
checkFn: func() error {
if time.Since(statusIRCMessageReceived) > statusIRCMessageReceivedTimeout {
return errors.New("message lifetime expired")

View file

@ -12,6 +12,7 @@ import (
"github.com/pkg/errors"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/crypt"
"github.com/Luzifer/twitch-bot/plugins"
)
@ -26,7 +27,10 @@ type storageFile struct {
GrantedScopes map[string][]string `json:"granted_scopes"`
EventSubSecret string `json:"event_sub_secret,omitempty"`
EventSubSecret string `encrypt:"true" json:"event_sub_secret,omitempty"`
BotAccessToken string `encrypt:"true" json:"bot_access_token,omitempty"`
BotRefreshToken string `encrypt:"true" json:"bot_refresh_token,omitempty"`
inMem bool
lock *sync.RWMutex
@ -65,6 +69,16 @@ func (s *storageFile) DeleteModuleStore(moduleUUID string) error {
return errors.Wrap(s.Save(), "saving store")
}
func (s *storageFile) GetBotToken(fallback string) string {
s.lock.RLock()
defer s.lock.RUnlock()
if v := s.BotAccessToken; v != "" {
return v
}
return fallback
}
func (s *storageFile) GetCounterValue(counter string) int64 {
s.lock.RLock()
defer s.lock.RUnlock()
@ -144,10 +158,15 @@ func (s *storageFile) Load() error {
}
defer zf.Close()
return errors.Wrap(
json.NewDecoder(zf).Decode(s),
"decode storage object",
)
if err = json.NewDecoder(zf).Decode(s); err != nil {
return errors.Wrap(err, "decode storage object")
}
if err = crypt.DecryptFields(s, cfg.StorageEncryptionPass); err != nil {
return errors.Wrap(err, "decrypting storage object")
}
return nil
}
func (s *storageFile) Save() error {
@ -172,6 +191,11 @@ func (s *storageFile) Save() error {
}
}
// Encrypt fields in memory before writing
if err := crypt.EncryptFields(s, cfg.StorageEncryptionPass); err != nil {
return errors.Wrap(err, "encrypting storage object")
}
// Write store to disk
f, err := os.Create(cfg.StorageFile)
if err != nil {
@ -182,16 +206,30 @@ func (s *storageFile) Save() error {
zf := gzip.NewWriter(f)
defer zf.Close()
return errors.Wrap(
json.NewEncoder(zf).Encode(s),
"encode storage object",
)
if err = json.NewEncoder(zf).Encode(s); err != nil {
return errors.Wrap(err, "encode storage object")
}
func (s *storageFile) SetGrantedScopes(user string, scopes []string) error {
// Decrypt the values to make them accessible again
if err = crypt.DecryptFields(s, cfg.StorageEncryptionPass); err != nil {
return errors.Wrap(err, "decrypting storage object")
}
return nil
}
func (s *storageFile) SetGrantedScopes(user string, scopes []string, merge bool) error {
s.lock.Lock()
defer s.lock.Unlock()
if merge {
for _, sc := range s.GrantedScopes[user] {
if !str.StringInSlice(sc, scopes) {
scopes = append(scopes, sc)
}
}
}
s.GrantedScopes[user] = scopes
return errors.Wrap(s.Save(), "saving store")
@ -238,6 +276,16 @@ func (s *storageFile) RemoveVariable(key string) error {
return errors.Wrap(s.Save(), "saving store")
}
func (s *storageFile) UpdateBotToken(accessToken, refreshToken string) error {
s.lock.Lock()
defer s.lock.Unlock()
s.BotAccessToken = accessToken
s.BotRefreshToken = refreshToken
return errors.Wrap(s.Save(), "saving store")
}
func (s *storageFile) UpdateCounter(counter string, value int64, absolute bool) error {
s.lock.Lock()
defer s.lock.Unlock()

View file

@ -48,6 +48,8 @@ const (
EventSubEventTypeStreamOnline = "stream.online"
EventSubEventTypeChannelPointCustomRewardRedemptionAdd = "channel.channel_points_custom_reward_redemption.add"
EventSubEventTypeUserAuthorizationRevoke = "user.authorization.revoke"
)
type (
@ -130,6 +132,13 @@ type (
StartedAt time.Time `json:"started_at"`
}
EventSubEventUserAuthorizationRevoke struct {
ClientID string `json:"client_id"`
UserID string `json:"user_id"`
UserLogin string `json:"user_login"`
UserName string `json:"user_name"`
}
eventSubPostMessage struct {
Challenge string `json:"challenge"`
Subscription eventSubSubscription `json:"subscription"`
@ -271,8 +280,25 @@ func (e *EventSubClient) PreFetchSubscriptions(ctx context.Context) error {
for i := range subList {
sub := subList[i]
if !str.StringInSlice(sub.Status, []string{eventSubStatusEnabled, eventSubStatusVerificationPending}) || sub.Transport.Callback != e.apiURL {
// Not our callback or not active
switch {
case !str.StringInSlice(sub.Status, []string{eventSubStatusEnabled, eventSubStatusVerificationPending}):
// Is not an active hook, we don't need to care: It will be
// confirmed later or will expire but should not be counted
continue
case strings.HasPrefix(sub.Transport.Callback, e.apiURL) && sub.Transport.Callback != e.fullAPIurl():
// Uses the same API URL but with another secret handle: Must
// have been registered by another instance with another secret
// so we should be able to deregister it without causing any
// trouble
logger := log.WithFields(log.Fields{
"id": sub.ID,
"topic": sub.Type,
})
logger.Debug("Removing deprecated EventSub subscription")
if err = e.twitchClient.deleteEventSubSubscription(ctx, sub.ID); err != nil {
logger.WithError(err).Error("Unable to deregister deprecated EventSub subscription")
}
continue
}
@ -330,7 +356,7 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
Condition: condition,
Transport: eventSubTransport{
Method: "webhook",
Callback: e.apiURL,
Callback: e.fullAPIurl(),
Secret: e.secret,
},
})
@ -357,6 +383,10 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
return func() { e.unregisterCallback(cacheKey, cbKey) }, nil
}
func (e *EventSubClient) fullAPIurl() string {
return strings.Join([]string{e.apiURL, e.secretHandle}, "/")
}
func (e *EventSubClient) unregisterCallback(cacheKey, cbKey string) {
e.subscriptionsLock.RLock()
regSub, ok := e.subscriptions[cacheKey]

View file

@ -1,6 +1,18 @@
package twitch
const (
// API Scopes
ScopeChannelManageRedemptions = "channel:manage:redemptions"
ScopeChannelReadRedemptions = "channel:read:redemptions"
ScopeChannelEditCommercial = "channel:edit:commercial"
ScopeChannelManageBroadcast = "channel:manage:broadcast"
ScopeChannelManagePolls = "channel:manage:polls"
ScopeChannelManagePredictions = "channel:manage:predictions"
// Chat Scopes
ScopeChannelModerate = "channel:moderate" // Perform moderation actions in a channel. The user requesting the scope must be a moderator in the channel.
ScopeChatEdit = "chat:edit" // Send live stream chat and rooms messages.
ScopeChatRead = "chat:read" // View live stream chat and rooms messages.
ScopeWhisperRead = "whispers:read" // View your whisper messages.
ScopeWhisperEdit = "whispers:edit" // Send whisper messages.
)

View file

@ -3,6 +3,7 @@ package twitch
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
@ -10,6 +11,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
@ -43,13 +45,33 @@ type (
Client struct {
clientID string
clientSecret string
token string
accessToken string
refreshToken string
tokenValidity time.Time
tokenUpdateHook func(string, string) error
appAccessToken string
apiCache *APICache
}
OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Scope []string `json:"scope"`
TokenType string `json:"token_type"`
}
OAuthTokenValidationResponse struct {
ClientID string `json:"client_id"`
Login string `json:"login"`
Scopes []string `json:"scopes"`
UserID string `json:"user_id"`
ExpiresIn int `json:"expires_in"`
}
StreamInfo struct {
ID string `json:"id"`
UserID string `json:"user_id"`
@ -81,25 +103,29 @@ type (
Body io.Reader
Context context.Context
Method string
NoRetry bool
NoValidateToken bool
OKStatus int
Out interface{}
URL string
}
)
func New(clientID, clientSecret, token string) *Client {
func New(clientID, clientSecret, accessToken, refreshToken string) *Client {
return &Client{
clientID: clientID,
clientSecret: clientSecret,
token: token,
accessToken: accessToken,
refreshToken: refreshToken,
apiCache: newTwitchAPICache(),
}
}
func (c Client) APICache() *APICache { return c.apiCache }
func (c *Client) APICache() *APICache { return c.apiCache }
func (c Client) GetAuthorizedUsername() (string, error) {
func (c *Client) GetAuthorizedUsername() (string, error) {
var payload struct {
Data []User `json:"data"`
}
@ -122,7 +148,7 @@ func (c Client) GetAuthorizedUsername() (string, error) {
return payload.Data[0].Login, nil
}
func (c Client) GetDisplayNameForUser(username string) (string, error) {
func (c *Client) GetDisplayNameForUser(username string) (string, error) {
cacheKey := []string{"displayNameForUsername", username}
if d := c.apiCache.Get(cacheKey); d != nil {
return d.(string), nil
@ -133,7 +159,7 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) {
}
if err := c.request(clientRequestOpts{
AuthType: authTypeBearerToken,
AuthType: authTypeAppAccessToken,
Context: context.Background(),
Method: http.MethodGet,
Out: &payload,
@ -152,7 +178,7 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) {
return payload.Data[0].DisplayName, nil
}
func (c Client) GetFollowDate(from, to string) (time.Time, error) {
func (c *Client) GetFollowDate(from, to string) (time.Time, error) {
cacheKey := []string{"followDate", from, to}
if d := c.apiCache.Get(cacheKey); d != nil {
return d.(time.Time), nil
@ -174,7 +200,7 @@ func (c Client) GetFollowDate(from, to string) (time.Time, error) {
}
if err := c.request(clientRequestOpts{
AuthType: authTypeBearerToken,
AuthType: authTypeAppAccessToken,
Context: context.Background(),
Method: http.MethodGet,
OKStatus: http.StatusOK,
@ -194,7 +220,19 @@ func (c Client) GetFollowDate(from, to string) (time.Time, error) {
return payload.Data[0].FollowedAt, nil
}
func (c Client) GetUserInformation(user string) (*User, error) {
func (c *Client) GetToken() (string, error) {
if err := c.ValidateToken(context.Background(), false); err != nil {
if err = c.RefreshToken(); err != nil {
return "", errors.Wrap(err, "refreshing token after validation error")
}
// Token was refreshed, therefore should now be valid
}
return c.accessToken, nil
}
func (c *Client) GetUserInformation(user string) (*User, error) {
var (
out User
param = "login"
@ -214,7 +252,7 @@ func (c Client) GetUserInformation(user string) (*User, error) {
}
if err := c.request(clientRequestOpts{
AuthType: authTypeBearerToken,
AuthType: authTypeAppAccessToken,
Context: context.Background(),
Method: http.MethodGet,
OKStatus: http.StatusOK,
@ -235,7 +273,7 @@ func (c Client) GetUserInformation(user string) (*User, error) {
return &out, nil
}
func (c Client) SearchCategories(ctx context.Context, name string) ([]Category, error) {
func (c *Client) SearchCategories(ctx context.Context, name string) ([]Category, error) {
var out []Category
params := make(url.Values)
@ -274,7 +312,7 @@ func (c Client) SearchCategories(ctx context.Context, name string) ([]Category,
return out, nil
}
func (c Client) HasLiveStream(username string) (bool, error) {
func (c *Client) HasLiveStream(username string) (bool, error) {
cacheKey := []string{"hasLiveStream", username}
if d := c.apiCache.Get(cacheKey); d != nil {
return d.(bool), nil
@ -305,7 +343,7 @@ func (c Client) HasLiveStream(username string) (bool, error) {
return len(payload.Data) == 1 && payload.Data[0].Type == "live", nil
}
func (c Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
func (c *Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
cacheKey := []string{"currentStreamInfo", username}
if si := c.apiCache.Get(cacheKey); si != nil {
return si.(*StreamInfo), nil
@ -341,7 +379,7 @@ func (c Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
return payload.Data[0], nil
}
func (c Client) GetIDForUsername(username string) (string, error) {
func (c *Client) GetIDForUsername(username string) (string, error) {
cacheKey := []string{"idForUsername", username}
if d := c.apiCache.Get(cacheKey); d != nil {
return d.(string), nil
@ -352,7 +390,7 @@ func (c Client) GetIDForUsername(username string) (string, error) {
}
if err := c.request(clientRequestOpts{
AuthType: authTypeBearerToken,
AuthType: authTypeAppAccessToken,
Context: context.Background(),
Method: http.MethodGet,
OKStatus: http.StatusOK,
@ -372,7 +410,7 @@ func (c Client) GetIDForUsername(username string) (string, error) {
return payload.Data[0].ID, nil
}
func (c Client) GetRecentStreamInfo(username string) (string, string, error) {
func (c *Client) GetRecentStreamInfo(username string) (string, string, error) {
cacheKey := []string{"recentStreamInfo", username}
if d := c.apiCache.Get(cacheKey); d != nil {
return d.([2]string)[0], d.([2]string)[1], nil
@ -413,7 +451,7 @@ func (c Client) GetRecentStreamInfo(username string) (string, string, error) {
return payload.Data[0].GameName, payload.Data[0].Title, nil
}
func (c Client) ModifyChannelInformation(ctx context.Context, broadcasterName string, game, title *string) error {
func (c *Client) ModifyChannelInformation(ctx context.Context, broadcasterName string, game, title *string) error {
if game == nil && title == nil {
return errors.New("netiher game nor title provided")
}
@ -478,8 +516,96 @@ func (c Client) ModifyChannelInformation(ctx context.Context, broadcasterName st
)
}
func (c *Client) UpdateToken(token string) {
c.token = token
func (c *Client) RefreshToken() error {
if c.refreshToken == "" {
return errors.New("no refresh token set")
}
params := make(url.Values)
params.Set("client_id", c.clientID)
params.Set("client_secret", c.clientSecret)
params.Set("refresh_token", c.refreshToken)
params.Set("grant_type", "refresh_token")
var resp OAuthTokenResponse
if err := c.request(clientRequestOpts{
AuthType: authTypeUnauthorized,
Context: context.Background(),
Method: http.MethodPost,
OKStatus: http.StatusOK,
Out: &resp,
URL: fmt.Sprintf("https://id.twitch.tv/oauth2/token?%s", params.Encode()),
}); err != nil {
// Retried refresh failed, wipe tokens
c.UpdateToken("", "")
if c.tokenUpdateHook != nil {
if herr := c.tokenUpdateHook("", ""); herr != nil {
log.WithError(err).Error("Unable to store token wipe after refresh failure")
}
}
return errors.Wrap(err, "executing request")
}
c.UpdateToken(resp.AccessToken, resp.RefreshToken)
c.tokenValidity = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)
log.WithField("expiry", c.tokenValidity).Trace("Access token refreshed")
if c.tokenUpdateHook == nil {
return nil
}
return errors.Wrap(c.tokenUpdateHook(resp.AccessToken, resp.RefreshToken), "calling token update hook")
}
func (c *Client) SetTokenUpdateHook(f func(string, string) error) {
c.tokenUpdateHook = f
}
func (c *Client) UpdateToken(accessToken, refreshToken string) {
c.accessToken = accessToken
c.refreshToken = refreshToken
}
func (c *Client) ValidateToken(ctx context.Context, force bool) error {
if c.tokenValidity.After(time.Now()) && !force {
// We do have an expiration time and it's not expired
// so we can assume we've checked the token and it should
// still be valid.
// NOTE(kahlers): In case of a token revokation this
// assumption is invalid and will lead to failing requests
return nil
}
if c.accessToken == "" {
return errors.New("no access token present")
}
var resp OAuthTokenValidationResponse
if err := c.request(clientRequestOpts{
AuthType: authTypeBearerToken,
Context: ctx,
Method: http.MethodGet,
NoRetry: true,
NoValidateToken: true,
OKStatus: http.StatusOK,
Out: &resp,
URL: "https://id.twitch.tv/oauth2/validate",
}); err != nil {
return errors.Wrap(err, "executing request")
}
if resp.ClientID != c.clientID {
return errors.New("token belongs to different app")
}
c.tokenValidity = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)
log.WithField("expiry", c.tokenValidity).Trace("Access token validated")
return nil
}
func (c *Client) createEventSubSubscription(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) {
@ -603,11 +729,11 @@ func (c *Client) getTwitchAppAccessToken() (string, error) {
func (c *Client) request(opts clientRequestOpts) error {
log.WithFields(log.Fields{
"method": opts.Method,
"url": opts.URL,
"url": c.replaceSecrets(opts.URL),
}).Trace("Execute Twitch API request")
var retries uint64 = twitchRequestRetries
if opts.Body != nil {
if opts.Body != nil || opts.NoRetry {
// Body must be read only once, do not retry
retries = 1
}
@ -636,7 +762,15 @@ func (c *Client) request(opts clientRequestOpts) error {
req.Header.Set("Client-Id", c.clientID)
case authTypeBearerToken:
req.Header.Set("Authorization", "Bearer "+c.token)
accessToken := c.accessToken
if !opts.NoValidateToken {
accessToken, err = c.GetToken()
if err != nil {
return errors.Wrap(err, "getting bearer access token")
}
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Client-Id", c.clientID)
default:
@ -667,3 +801,26 @@ func (c *Client) request(opts clientRequestOpts) error {
)
})
}
func (c *Client) replaceSecrets(u string) string {
var replacements []string
for _, secret := range []string{
c.accessToken,
c.refreshToken,
c.clientSecret,
} {
if secret == "" {
continue
}
replacements = append(replacements, secret, c.hashSecret(secret))
}
return strings.NewReplacer(replacements...).Replace(u)
}
func (*Client) hashSecret(secret string) string {
h := sha256.New()
h.Write([]byte(secret))
return fmt.Sprintf("[sha256:%x]", h.Sum(nil))
}

View file

@ -17,6 +17,7 @@ type (
IsLive bool
Title string
isInitialized bool
unregisterFunc func()
}
@ -33,6 +34,12 @@ func (t twitchChannelState) Equals(c twitchChannelState) bool {
t.Title == c.Title
}
func (t *twitchChannelState) Update(c twitchChannelState) {
t.Category = c.Category
t.IsLive = c.IsLive
t.Title = c.Title
}
func newTwitchWatcher() *twitchWatcher {
return &twitchWatcher{
ChannelStatus: make(map[string]*twitchChannelState),
@ -48,7 +55,12 @@ func (t *twitchWatcher) AddChannel(channel string) error {
return nil
}
return t.updateChannelFromAPI(channel, false)
// Initialize for check loop
t.lock.Lock()
t.ChannelStatus[channel] = &twitchChannelState{}
t.lock.Unlock()
return t.updateChannelFromAPI(channel)
}
func (t *twitchWatcher) Check() {
@ -64,7 +76,7 @@ func (t *twitchWatcher) Check() {
t.lock.RUnlock()
for _, ch := range channels {
if err := t.updateChannelFromAPI(ch, true); err != nil {
if err := t.updateChannelFromAPI(ch); err != nil {
log.WithError(err).WithField("channel", ch).Error("Unable to update channel status")
}
}
@ -147,10 +159,31 @@ func (t *twitchWatcher) handleEventSubStreamOnOff(isOnline bool) func(json.RawMe
}
}
func (t *twitchWatcher) updateChannelFromAPI(channel string, sendUpdate bool) error {
func (t *twitchWatcher) handleEventUserAuthRevoke(m json.RawMessage) error {
var payload twitch.EventSubEventUserAuthorizationRevoke
if err := json.Unmarshal(m, &payload); err != nil {
return errors.Wrap(err, "unmarshalling event")
}
if payload.ClientID != cfg.TwitchClient {
// We got an revoke for a different ID: Shouldn't happen but whatever.
return nil
}
return errors.Wrap(
store.DeleteGrantedScopes(payload.UserLogin),
"deleting granted scopes",
)
}
func (t *twitchWatcher) updateChannelFromAPI(channel string) error {
t.lock.Lock()
defer t.lock.Unlock()
var (
err error
status twitchChannelState
storedStatus = t.ChannelStatus[channel]
)
status.IsLive, err = twitchClient.HasLiveStream(channel)
@ -163,23 +196,28 @@ func (t *twitchWatcher) updateChannelFromAPI(channel string, sendUpdate bool) er
return errors.Wrap(err, "getting stream info")
}
t.lock.Lock()
defer t.lock.Unlock()
if t.ChannelStatus[channel] != nil && t.ChannelStatus[channel].Equals(status) {
return nil
if storedStatus == nil {
storedStatus = &twitchChannelState{}
t.ChannelStatus[channel] = storedStatus
}
if sendUpdate && t.ChannelStatus[channel] != nil {
if storedStatus.isInitialized && !storedStatus.Equals(status) {
// Send updates only when we do have an update
t.triggerUpdate(channel, &status.Title, &status.Category, &status.IsLive)
}
storedStatus.Update(status)
storedStatus.isInitialized = true
if storedStatus.unregisterFunc != nil {
// Do not register twice
return nil
}
if status.unregisterFunc, err = t.registerEventSubCallbacks(channel); err != nil {
if storedStatus.unregisterFunc, err = t.registerEventSubCallbacks(channel); err != nil {
return errors.Wrap(err, "registering eventsub callbacks")
}
t.ChannelStatus[channel] = &status
return nil
}
@ -260,7 +298,13 @@ func (t *twitchWatcher) registerEventSubCallbacks(channel string) (func(), error
uf, err := twitchEventSubClient.RegisterEventSubHooks(tr.Topic, tr.Condition, tr.Hook)
if err != nil {
logger.WithError(err).Error("Unable to register topic")
continue
for _, f := range unsubHandlers {
// Error will cause unsub handlers not to be stored, therefore we unsub them now
f()
}
return nil, errors.Wrap(err, "registering topic")
}
unsubHandlers = append(unsubHandlers, uf)
@ -273,6 +317,16 @@ func (t *twitchWatcher) registerEventSubCallbacks(channel string) (func(), error
}, nil
}
func (t *twitchWatcher) registerGlobalHooks() error {
_, err := twitchEventSubClient.RegisterEventSubHooks(
twitch.EventSubEventTypeUserAuthorizationRevoke,
twitch.EventSubCondition{ClientID: cfg.TwitchClient},
t.handleEventUserAuthRevoke,
)
return errors.Wrap(err, "registering user auth hook")
}
func (t *twitchWatcher) triggerUpdate(channel string, title, category *string, online *bool) {
if category != nil && t.ChannelStatus[channel].Category != *category {
t.ChannelStatus[channel].Category = *category