[core] Implement write authorization for APIs (#9)

This commit is contained in:
Knut Ahlers 2021-10-23 17:22:58 +02:00
parent ed15c532d3
commit 77aa2e411c
Signed by: luzifer
GPG key ID: 0066F03ED215AD7D
15 changed files with 485 additions and 64 deletions

View file

@ -27,4 +27,10 @@ Usage of twitch-bot:
--twitch-token string OAuth token valid for client --twitch-token string OAuth token valid for client
-v, --validate-config Loads the config, logs any errors and quits with status 0 on success -v, --validate-config Loads the config, logs any errors and quits with status 0 on success
--version Prints current version and exits --version Prints current version and exits
# twitch-bot help
Supported sub-commands are:
actor-docs Generate markdown documentation for available actors
api-token <name> <scope...> Generate an api-token to be entered into the config
help Prints this help message
``` ```

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/internal/actors/ban" "github.com/Luzifer/twitch-bot/internal/actors/ban"
"github.com/Luzifer/twitch-bot/internal/actors/delay" "github.com/Luzifer/twitch-bot/internal/actors/delay"
deleteactor "github.com/Luzifer/twitch-bot/internal/actors/delete" deleteactor "github.com/Luzifer/twitch-bot/internal/actors/delete"
@ -20,18 +21,21 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
var coreActorRegistations = []plugins.RegisterFunc{ var (
ban.Register, coreActorRegistations = []plugins.RegisterFunc{
delay.Register, ban.Register,
deleteactor.Register, delay.Register,
modchannel.Register, deleteactor.Register,
punish.Register, modchannel.Register,
quotedb.Register, punish.Register,
raw.Register, quotedb.Register,
respond.Register, raw.Register,
timeout.Register, respond.Register,
whisper.Register, timeout.Register,
} whisper.Register,
}
knownModules []string
)
func initCorePlugins() error { func initCorePlugins() error {
args := getRegistrationArguments() args := getRegistrationArguments()
@ -48,9 +52,16 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error {
PathPrefix(fmt.Sprintf("/%s/", route.Module)). PathPrefix(fmt.Sprintf("/%s/", route.Module)).
Subrouter() Subrouter()
if !str.StringInSlice(route.Module, knownModules) {
knownModules = append(knownModules, route.Module)
}
var hdl http.Handler = route.HandlerFunc var hdl http.Handler = route.HandlerFunc
if route.RequiresEditorsAuth { switch {
case route.RequiresEditorsAuth:
hdl = botEditorAuthMiddleware(hdl) hdl = botEditorAuthMiddleware(hdl)
case route.RequiresWriteAuth:
hdl = writeAuthMiddleware(hdl, route.Module)
} }
if route.IsPrefix { if route.IsPrefix {

View file

@ -95,6 +95,7 @@ func init() {
Type: "int64", Type: "int64",
}, },
}, },
RequiresWriteAuth: true,
RouteParams: []plugins.HTTPRouteParamDocumentation{ RouteParams: []plugins.HTTPRouteParamDocumentation{
{ {
Description: "Name of the counter to update", Description: "Name of the counter to update",

View file

@ -80,6 +80,7 @@ func init() {
Type: "string", Type: "string",
}, },
}, },
RequiresWriteAuth: true,
RouteParams: []plugins.HTTPRouteParamDocumentation{ RouteParams: []plugins.HTTPRouteParamDocumentation{
{ {
Description: "Name of the variable to update", Description: "Name of the variable to update",

View file

@ -46,21 +46,29 @@ func registerConfigReloadHook(hook func()) func() {
} }
type ( type (
configAuthToken struct {
Hash string `json:"-" yaml:"hash"`
Modules []string `json:"modules" yaml:"modules"`
Name string `json:"name" yaml:"name"`
Token string `json:"token" yaml:"-"`
}
configFileVersioner struct { configFileVersioner struct {
ConfigVersion int64 `yaml:"config_version"` ConfigVersion int64 `yaml:"config_version"`
} }
configFile struct { configFile struct {
AutoMessages []*autoMessage `yaml:"auto_messages"` AuthTokens map[string]configAuthToken `yaml:"auth_tokens"`
BotEditors []string `yaml:"bot_editors"` AutoMessages []*autoMessage `yaml:"auto_messages"`
Channels []string `yaml:"channels"` BotEditors []string `yaml:"bot_editors"`
GitTrackConfig bool `yaml:"git_track_config"` Channels []string `yaml:"channels"`
HTTPListen string `yaml:"http_listen"` GitTrackConfig bool `yaml:"git_track_config"`
PermitAllowModerator bool `yaml:"permit_allow_moderator"` HTTPListen string `yaml:"http_listen"`
PermitTimeout time.Duration `yaml:"permit_timeout"` PermitAllowModerator bool `yaml:"permit_allow_moderator"`
RawLog string `yaml:"raw_log"` PermitTimeout time.Duration `yaml:"permit_timeout"`
Rules []*plugins.Rule `yaml:"rules"` RawLog string `yaml:"raw_log"`
Variables map[string]interface{} `yaml:"variables"` Rules []*plugins.Rule `yaml:"rules"`
Variables map[string]interface{} `yaml:"variables"`
rawLogWriter io.WriteCloser rawLogWriter io.WriteCloser
@ -70,6 +78,7 @@ type (
func newConfigFile() *configFile { func newConfigFile() *configFile {
return &configFile{ return &configFile{
AuthTokens: map[string]configAuthToken{},
PermitTimeout: time.Minute, PermitTimeout: time.Minute,
} }
} }

View file

@ -5,6 +5,8 @@ import (
"net/http" "net/http"
"github.com/Luzifer/twitch-bot/plugins" "github.com/Luzifer/twitch-bot/plugins"
"github.com/gofrs/uuid/v3"
"github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -18,6 +20,44 @@ type (
func registerEditorGeneralConfigRoutes() { func registerEditorGeneralConfigRoutes() {
for _, rd := range []plugins.HTTPRouteRegistrationArgs{ for _, rd := range []plugins.HTTPRouteRegistrationArgs{
{
Description: "Add new authorization token",
HandlerFunc: configEditorHandleGeneralAddAuthToken,
Method: http.MethodPost,
Module: "config-editor",
Name: "Add authorization token",
Path: "/auth-tokens",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{
Description: "Delete authorization token",
HandlerFunc: configEditorHandleGeneralDeleteAuthToken,
Method: http.MethodDelete,
Module: "config-editor",
Name: "Delete authorization token",
Path: "/auth-tokens/{handle}",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
RouteParams: []plugins.HTTPRouteParamDocumentation{
{
Description: "UUID of the auth-token to delete",
Name: "handle",
Required: true,
Type: "string",
},
},
},
{
Description: "List authorization tokens",
HandlerFunc: configEditorHandleGeneralListAuthTokens,
Method: http.MethodGet,
Module: "config-editor",
Name: "List authorization tokens",
Path: "/auth-tokens",
RequiresEditorsAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{ {
Description: "Returns the current general config", Description: "Returns the current general config",
HandlerFunc: configEditorHandleGeneralGet, HandlerFunc: configEditorHandleGeneralGet,
@ -45,6 +85,56 @@ func registerEditorGeneralConfigRoutes() {
} }
} }
func configEditorHandleGeneralAddAuthToken(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return
}
var payload configAuthToken
if err = json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, errors.Wrap(err, "reading payload").Error(), http.StatusBadRequest)
return
}
if err = fillAuthToken(&payload); err != nil {
http.Error(w, errors.Wrap(err, "hashing token").Error(), http.StatusInternalServerError)
return
}
if err := patchConfig(cfg.Config, user, "", "Add auth-token", func(cfg *configFile) error {
cfg.AuthTokens[uuid.Must(uuid.NewV4()).String()] = payload
return nil
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err = json.NewEncoder(w).Encode(payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r)
if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
}
if err := patchConfig(cfg.Config, user, "", "Delete auth-token", func(cfg *configFile) error {
delete(cfg.AuthTokens, mux.Vars(r)["handle"])
return nil
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) { 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,
@ -54,6 +144,12 @@ func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
} }
} }
func configEditorHandleGeneralListAuthTokens(w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(config.AuthTokens); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func configEditorHandleGeneralUpdate(w http.ResponseWriter, r *http.Request) { func configEditorHandleGeneralUpdate(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r) user, _, err := getAuthorizationFromRequest(r)
if err != nil { if err != nil {

View file

@ -21,6 +21,15 @@ func registerEditorGlobalMethods() {
Path: "/actions", Path: "/actions",
ResponseType: plugins.HTTPRouteResponseTypeJSON, ResponseType: plugins.HTTPRouteResponseTypeJSON,
}, },
{
Description: "Returns all available modules for auth",
HandlerFunc: configEditorGlobalGetModules,
Method: http.MethodGet,
Module: "config-editor",
Name: "Get available modules",
Path: "/modules",
ResponseType: plugins.HTTPRouteResponseTypeJSON,
},
{ {
Description: "Returns information about a Twitch user to properly display bot editors", Description: "Returns information about a Twitch user to properly display bot editors",
HandlerFunc: configEditorGlobalGetUser, HandlerFunc: configEditorGlobalGetUser,
@ -104,6 +113,12 @@ func configEditorGlobalGetActions(w http.ResponseWriter, r *http.Request) {
} }
} }
func configEditorGlobalGetModules(w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(knownModules); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func configEditorGlobalGetUser(w http.ResponseWriter, r *http.Request) { func configEditorGlobalGetUser(w http.ResponseWriter, r *http.Request) {
usr, err := twitchClient.GetUserInformation(r.FormValue("user")) usr, err := twitchClient.GetUserInformation(r.FormValue("user"))
if err != nil { if err != nil {

View file

@ -43,6 +43,15 @@ new Vue({
] ]
}, },
availableModules() {
return [
{ text: 'ANY', value: '*' },
...this.modules.sort()
.filter(m => m !== 'config-editor')
.map(m => ({ text: m, value: m })),
]
},
axiosOptions() { axiosOptions() {
return { return {
headers: { headers: {
@ -95,6 +104,10 @@ new Vue({
}) })
}, },
validateAPIToken() {
return this.models.apiToken.modules.length > 0 && Boolean(this.models.apiToken.name)
},
validateAutoMessage() { validateAutoMessage() {
if (!this.models.autoMessage.sendMode) { if (!this.models.autoMessage.sendMode) {
return false return false
@ -183,6 +196,7 @@ new Vue({
data: { data: {
actions: [], actions: [],
apiTokens: {},
authToken: null, authToken: null,
autoMessageFields: [ autoMessageFields: [
{ {
@ -221,6 +235,7 @@ new Vue({
configNotifySocket: null, configNotifySocket: null,
configNotifySocketConnected: false, configNotifySocketConnected: false,
configNotifyBackoff: 100, configNotifyBackoff: 100,
createdAPIToken: null,
editMode: 'general', editMode: 'general',
error: null, error: null,
generalConfig: {}, generalConfig: {},
@ -228,10 +243,12 @@ new Vue({
addAction: '', addAction: '',
addChannel: '', addChannel: '',
addEditor: '', addEditor: '',
apiToken: {},
autoMessage: {}, autoMessage: {},
rule: {}, rule: {},
}, },
modules: [],
rules: [], rules: [],
rulesFields: [ rulesFields: [
{ {
@ -254,6 +271,7 @@ new Vue({
}, },
], ],
showAPITokenEditModal: false,
showAutoMessageEditModal: false, showAutoMessageEditModal: false,
showRuleEditModal: false, showRuleEditModal: false,
userProfiles: {}, userProfiles: {},
@ -344,6 +362,14 @@ new Vue({
.catch(err => this.handleFetchError(err)) .catch(err => this.handleFetchError(err))
}, },
fetchAPITokens() {
return axios.get('config-editor/auth-tokens', this.axiosOptions)
.then(resp => {
this.apiTokens = resp.data
})
.catch(err => this.handleFetchError(err))
},
fetchAutoMessages() { fetchAutoMessages() {
return axios.get('config-editor/auto-messages', this.axiosOptions) return axios.get('config-editor/auto-messages', this.axiosOptions)
.then(resp => { .then(resp => {
@ -367,6 +393,14 @@ new Vue({
}) })
}, },
fetchModules() {
return axios.get('config-editor/modules')
.then(resp => {
this.modules = resp.data
})
.catch(err => this.handleFetchError(err))
},
fetchProfile(user) { fetchProfile(user) {
return axios.get(`config-editor/user?user=${user}`, this.axiosOptions) return axios.get(`config-editor/user?user=${user}`, this.axiosOptions)
.then(resp => Vue.set(this.userProfiles, user, resp.data)) .then(resp => Vue.set(this.userProfiles, user, resp.data))
@ -500,6 +534,14 @@ new Vue({
Vue.set(this.models.rule, 'actions', tmp) Vue.set(this.models.rule, 'actions', tmp)
}, },
newAPIToken() {
Vue.set(this.models, 'apiToken', {
name: '',
modules: [],
})
this.showAPITokenEditModal = true
},
newAutoMessage() { newAutoMessage() {
Vue.set(this.models, 'autoMessage', {}) Vue.set(this.models, 'autoMessage', {})
this.showAutoMessageEditModal = true this.showAutoMessageEditModal = true
@ -545,6 +587,7 @@ new Vue({
reload() { reload() {
return Promise.all([ return Promise.all([
this.fetchAPITokens(),
this.fetchAutoMessages(), this.fetchAutoMessages(),
this.fetchGeneralConfig(), this.fetchGeneralConfig(),
this.fetchRules(), this.fetchRules(),
@ -557,6 +600,14 @@ new Vue({
this.models.rule.actions = this.models.rule.actions.filter((_, i) => i !== idx) this.models.rule.actions = this.models.rule.actions.filter((_, i) => i !== idx)
}, },
removeAPIToken(uuid) {
axios.delete(`config-editor/auth-tokens/${uuid}`, this.axiosOptions)
.then(() => {
this.changePending = true
})
.catch(err => this.handleFetchError(err))
},
removeChannel(channel) { removeChannel(channel) {
this.generalConfig.channels = this.generalConfig.channels this.generalConfig.channels = this.generalConfig.channels
.filter(ch => ch !== channel) .filter(ch => ch !== channel)
@ -571,9 +622,27 @@ new Vue({
this.updateGeneralConfig() this.updateGeneralConfig()
}, },
saveAPIToken() {
if (!this.validateAPIToken) {
evt.preventDefault()
return
}
axios.post(`config-editor/auth-tokens`, this.models.apiToken, this.axiosOptions)
.then(resp => {
this.createdAPIToken = resp.data
this.changePending = true
window.setTimeout(() => {
this.createdAPIToken = null
}, 30000)
})
.catch(err => this.handleFetchError(err))
},
saveAutoMessage(evt) { saveAutoMessage(evt) {
if (!this.validateAutoMessage) { if (!this.validateAutoMessage) {
evt.preventDefault() evt.preventDefault()
return
} }
const obj = { ...this.models.autoMessage } const obj = { ...this.models.autoMessage }
@ -602,6 +671,7 @@ new Vue({
saveRule(evt) { saveRule(evt) {
if (!this.validateRule) { if (!this.validateRule) {
evt.preventDefault() evt.preventDefault()
return
} }
const obj = { const obj = {
@ -786,6 +856,7 @@ new Vue({
mounted() { mounted() {
this.fetchVars() this.fetchVars()
this.fetchActions() this.fetchActions()
this.fetchModules()
const params = new URLSearchParams(window.location.hash.substring(1)) const params = new URLSearchParams(window.location.hash.substring(1))
this.authToken = params.get('access_token') || null this.authToken = params.get('access_token') || null

View file

@ -179,6 +179,47 @@
</b-list-group> </b-list-group>
</b-card> </b-card>
<b-card no-body>
<b-card-header
class="d-flex align-items-center align-middle"
>
<span class="mr-auto"><i class="fas fa-fw fa-ticket-alt mr-1"></i> Auth-Tokens</span>
<b-button-group size="sm">
<b-button @click="newAPIToken" variant="success"><i class="fas fa-fw fa-plus"></i></b-button>
</b-button-group>
</b-card-header>
<b-list-group flush>
<b-list-group-item
variant="success"
v-if="createdAPIToken"
>
Token was created, copy it within 30s as you will not see it again:<br>
<code>{{ createdAPIToken.token }}</code>
</b-list-group-item>
<b-list-group-item
class="d-flex align-items-center align-middle"
:key="uuid"
v-for="(token, uuid) in apiTokens"
>
<span class="mr-auto">
{{ token.name }}<br>
<b-badge
:key="module"
v-for="module in token.modules"
>{{ module === '*' ? 'ANY' : module }}</b-badge>
</span>
<b-button
@click="removeAPIToken(uuid)"
size="sm"
variant="danger"
>
<i class="fas fa-fw fa-minus"></i>
</b-button>
</b-list-group-item>
</b-list-group>
</b-card>
</b-card-group> </b-card-group>
</b-col> </b-col>
</b-row> </b-row>
@ -267,6 +308,43 @@
</template> </template>
<!-- API-Token Editor -->
<b-modal
@hidden="showAPITokenEditModal=false"
hide-header-close
@ok="saveAPIToken"
:ok-disabled="!validateAPIToken"
ok-title="Save"
size="md"
:visible="showAPITokenEditModal"
title="New API-Token"
v-if="showAPITokenEditModal"
>
<b-form-group
label="Name"
label-for="formAPITokenName"
>
<b-form-input
id="formAPITokenName"
v-model="models.apiToken.name"
:state="Boolean(models.apiToken.name)"
type="text"
></b-form-input>
</b-form-group>
<b-form-group
label="Enabled for Modules"
>
<b-form-checkbox-group
class="mb-3"
:options="availableModules"
text-field="text"
value-field="value"
v-model="models.apiToken.modules"
></b-form-checkbox-group>
</b-form-group>
</b-modal>
<!-- Auto-Message Editor --> <!-- Auto-Message Editor -->
<b-modal <b-modal
@hidden="showAutoMessageEditModal=false" @hidden="showAutoMessageEditModal=false"

View file

@ -29,13 +29,14 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
}) })
register(plugins.HTTPRouteRegistrationArgs{ register(plugins.HTTPRouteRegistrationArgs{
Description: "Add quotes for the given {channel}", Description: "Add quotes for the given {channel}",
HandlerFunc: handleAddQuotes, HandlerFunc: handleAddQuotes,
Method: http.MethodPost, Method: http.MethodPost,
Module: "quotedb", Module: "quotedb",
Name: "Add Quotes", Name: "Add Quotes",
Path: "/{channel}", Path: "/{channel}",
ResponseType: plugins.HTTPRouteResponseTypeTextPlain, RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
RouteParams: []plugins.HTTPRouteParamDocumentation{ RouteParams: []plugins.HTTPRouteParamDocumentation{
{ {
Description: "Channel to delete the quote in", Description: "Channel to delete the quote in",
@ -45,13 +46,14 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
}) })
register(plugins.HTTPRouteRegistrationArgs{ register(plugins.HTTPRouteRegistrationArgs{
Description: "Deletes quote with given {idx} from {channel}", Description: "Deletes quote with given {idx} from {channel}",
HandlerFunc: handleDeleteQuote, HandlerFunc: handleDeleteQuote,
Method: http.MethodDelete, Method: http.MethodDelete,
Module: "quotedb", Module: "quotedb",
Name: "Delete Quote", Name: "Delete Quote",
Path: "/{channel}/{idx:[0-9]+}", Path: "/{channel}/{idx:[0-9]+}",
ResponseType: plugins.HTTPRouteResponseTypeTextPlain, RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
RouteParams: []plugins.HTTPRouteParamDocumentation{ RouteParams: []plugins.HTTPRouteParamDocumentation{
{ {
Description: "Channel to delete the quote in", Description: "Channel to delete the quote in",
@ -82,13 +84,14 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
}) })
register(plugins.HTTPRouteRegistrationArgs{ register(plugins.HTTPRouteRegistrationArgs{
Description: "Set quotes for the given {channel} (will overwrite ALL quotes!)", Description: "Set quotes for the given {channel} (will overwrite ALL quotes!)",
HandlerFunc: handleReplaceQuotes, HandlerFunc: handleReplaceQuotes,
Method: http.MethodPut, Method: http.MethodPut,
Module: "quotedb", Module: "quotedb",
Name: "Set Quotes", Name: "Set Quotes",
Path: "/{channel}", Path: "/{channel}",
ResponseType: plugins.HTTPRouteResponseTypeTextPlain, RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
RouteParams: []plugins.HTTPRouteParamDocumentation{ RouteParams: []plugins.HTTPRouteParamDocumentation{
{ {
Description: "Channel to delete the quote in", Description: "Channel to delete the quote in",
@ -98,13 +101,14 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
}) })
register(plugins.HTTPRouteRegistrationArgs{ register(plugins.HTTPRouteRegistrationArgs{
Description: "Updates quote with given {idx} from {channel}", Description: "Updates quote with given {idx} from {channel}",
HandlerFunc: handleUpdateQuote, HandlerFunc: handleUpdateQuote,
Method: http.MethodPut, Method: http.MethodPut,
Module: "quotedb", Module: "quotedb",
Name: "Update Quote", Name: "Update Quote",
Path: "/{channel}/{idx:[0-9]+}", Path: "/{channel}/{idx:[0-9]+}",
ResponseType: plugins.HTTPRouteResponseTypeTextPlain, RequiresWriteAuth: true,
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
RouteParams: []plugins.HTTPRouteParamDocumentation{ RouteParams: []plugins.HTTPRouteParamDocumentation{
{ {
Description: "Channel to delete the quote in", Description: "Channel to delete the quote in",

60
main.go
View file

@ -11,10 +11,12 @@ import (
"time" "time"
"github.com/go-irc/irc" "github.com/go-irc/irc"
"github.com/gofrs/uuid/v3"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
"github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/rconfig/v2" "github.com/Luzifer/rconfig/v2"
@ -79,6 +81,54 @@ func init() {
} }
} }
func handleSubCommand(args []string) {
switch args[0] {
case "actor-docs":
doc, err := generateActorDocs()
if err != nil {
log.WithError(err).Fatal("Unable to generate actor docs")
}
if _, err = os.Stdout.Write(append(bytes.TrimSpace(doc), '\n')); err != nil {
log.WithError(err).Fatal("Unable to write actor docs to stdout")
}
case "api-token":
if len(args) < 3 { //nolint:gomnd // Just a count of parameters
log.Fatalf("Usage: twitch-bot api-token <token name> <scope> [...scope]")
}
t := configAuthToken{
Name: args[1],
Modules: args[2:],
}
if err := fillAuthToken(&t); err != nil {
log.WithError(err).Fatal("Unable to generate token")
}
log.WithField("token", t.Token).Info("Token generated, add this to your config:")
if err := yaml.NewEncoder(os.Stdout).Encode(map[string]map[string]configAuthToken{
"auth_tokens": {
uuid.Must(uuid.NewV4()).String(): t,
},
}); err != nil {
log.WithError(err).Fatal("Unable to output token info")
}
case "help":
fmt.Println("Supported sub-commands are:")
fmt.Println(" actor-docs Generate markdown documentation for available actors")
fmt.Println(" api-token <name> <scope...> Generate an api-token to be entered into the config")
fmt.Println(" help Prints this help message")
default:
handleSubCommand([]string{"help"})
log.Fatalf("Unknown sub-command %q", args[0])
}
}
//nolint: funlen,gocognit,gocyclo // Complexity is a little too high but makes no sense to split //nolint: funlen,gocognit,gocyclo // Complexity is a little too high but makes no sense to split
func main() { func main() {
var err error var err error
@ -105,14 +155,8 @@ func main() {
log.WithError(err).Fatal("Unable to load plugins") log.WithError(err).Fatal("Unable to load plugins")
} }
if len(rconfig.Args()) == 2 && rconfig.Args()[1] == "actor-docs" { if len(rconfig.Args()) > 1 {
doc, err := generateActorDocs() handleSubCommand(rconfig.Args()[1:])
if err != nil {
log.WithError(err).Fatal("Unable to generate actor docs")
}
if _, err = os.Stdout.Write(append(bytes.TrimSpace(doc), '\n')); err != nil {
log.WithError(err).Fatal("Unable to write actor docs to stdout")
}
return return
} }

View file

@ -21,6 +21,7 @@ type (
Path string Path string
QueryParams []HTTPRouteParamDocumentation QueryParams []HTTPRouteParamDocumentation
RequiresEditorsAuth bool RequiresEditorsAuth bool
RequiresWriteAuth bool
ResponseType HTTPRouteResponseType ResponseType HTTPRouteResponseType
RouteParams []HTTPRouteParamDocumentation RouteParams []HTTPRouteParamDocumentation
SkipDocumentation bool SkipDocumentation bool

View file

@ -31,9 +31,6 @@ var (
"inputErrorResponse": spec.TextPlainResponse(nil).WithDescription("Data sent to API is invalid: See error message"), "inputErrorResponse": spec.TextPlainResponse(nil).WithDescription("Data sent to API is invalid: See error message"),
"notFoundResponse": spec.TextPlainResponse(nil).WithDescription("Document was not found or insufficient permissions"), "notFoundResponse": spec.TextPlainResponse(nil).WithDescription("Document was not found or insufficient permissions"),
}, },
SecuritySchemes: map[string]*spec.SecurityScheme{
"authenticated": spec.APIKeyAuth("Authorization", spec.InHeader),
},
}, },
} }
@ -41,6 +38,19 @@ var (
swaggerHTML []byte swaggerHTML []byte
) )
func init() {
secConfigEditor := spec.APIKeyAuth("Authorization", spec.InHeader)
secConfigEditor.Description = "Authorization token issued by Twitch"
secWriteAuth := spec.APIKeyAuth("Authorization", spec.InHeader)
secWriteAuth.Description = "Authorization token stored in the config"
swaggerDoc.Components.SecuritySchemes = map[string]*spec.SecurityScheme{
"configEditor": secConfigEditor,
"writeAuth": secWriteAuth,
}
}
func handleSwaggerHTML(w http.ResponseWriter, r *http.Request) { func handleSwaggerHTML(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
@ -90,9 +100,15 @@ func registerSwaggerRoute(route plugins.HTTPRouteRegistrationArgs) error {
}, },
} }
if route.RequiresEditorsAuth { switch {
case route.RequiresEditorsAuth:
op.Security = []map[string]spec.SecurityRequirement{ op.Security = []map[string]spec.SecurityRequirement{
{"authenticated": {}}, {"configEditor": {}},
}
case route.RequiresWriteAuth:
op.Security = []map[string]spec.SecurityRequirement{
{"writeAuth": {}},
} }
} }

View file

@ -8,6 +8,18 @@
# upgrade. # upgrade.
config_version: 2 config_version: 2
# List of tokens allowed to access the HTTP API with write access.
# You can generate a token using the web-based config-editor or the
# `api-token` sub-command:
# $ twitch-bot api-token 'mytoken' '*'
# The token will only be printed ONCE and cannot be retrieved afterards.
auth_tokens:
89196495-68eb-4f50-94f0-5c5d99f26be5:
hash: '243261[...]36532e'
modules:
- '*'
name: mytoken
# List of strings: Either Twitch user-ids or nicknames (best to stick # List of strings: Either Twitch user-ids or nicknames (best to stick
# with IDs as they can't change while nicknames can be changed every # with IDs as they can't change while nicknames can be changed every
# 60 days). Those users are able to use the config editor web-interface. # 60 days). Those users are able to use the config editor web-interface.

56
writeAuth.go Normal file
View file

@ -0,0 +1,56 @@
package main
import (
"encoding/hex"
"net/http"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/gofrs/uuid/v3"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
func fillAuthToken(token *configAuthToken) error {
token.Token = uuid.Must(uuid.NewV4()).String()
hash, err := bcrypt.GenerateFromPassword([]byte(token.Token), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(err, "hashing token")
}
token.Hash = hex.EncodeToString(hash)
return nil
}
func writeAuthMiddleware(h http.Handler, module string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "auth not successful", http.StatusForbidden)
return
}
for _, auth := range config.AuthTokens {
rawHash, err := hex.DecodeString(auth.Hash)
if err != nil {
log.WithError(err).Error("Invalid token hash found")
continue
}
if bcrypt.CompareHashAndPassword(rawHash, []byte(token)) != nil {
continue
}
if !str.StringInSlice(module, auth.Modules) && !str.StringInSlice("*", auth.Modules) {
continue
}
h.ServeHTTP(w, r)
return
}
http.Error(w, "auth not successful", http.StatusForbidden)
})
}