diff --git a/README.md b/README.md index f86dd8f..deb3a89 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,10 @@ Usage of twitch-bot: --twitch-token string OAuth token valid for client -v, --validate-config Loads the config, logs any errors and quits with status 0 on success --version Prints current version and exits + +# twitch-bot help +Supported sub-commands are: + actor-docs Generate markdown documentation for available actors + api-token Generate an api-token to be entered into the config + help Prints this help message ``` diff --git a/action_core.go b/action_core.go index ba2e3ea..c6db244 100644 --- a/action_core.go +++ b/action_core.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" + "github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/twitch-bot/internal/actors/ban" "github.com/Luzifer/twitch-bot/internal/actors/delay" deleteactor "github.com/Luzifer/twitch-bot/internal/actors/delete" @@ -20,18 +21,21 @@ import ( log "github.com/sirupsen/logrus" ) -var coreActorRegistations = []plugins.RegisterFunc{ - ban.Register, - delay.Register, - deleteactor.Register, - modchannel.Register, - punish.Register, - quotedb.Register, - raw.Register, - respond.Register, - timeout.Register, - whisper.Register, -} +var ( + coreActorRegistations = []plugins.RegisterFunc{ + ban.Register, + delay.Register, + deleteactor.Register, + modchannel.Register, + punish.Register, + quotedb.Register, + raw.Register, + respond.Register, + timeout.Register, + whisper.Register, + } + knownModules []string +) func initCorePlugins() error { args := getRegistrationArguments() @@ -48,9 +52,16 @@ func registerRoute(route plugins.HTTPRouteRegistrationArgs) error { PathPrefix(fmt.Sprintf("/%s/", route.Module)). Subrouter() + if !str.StringInSlice(route.Module, knownModules) { + knownModules = append(knownModules, route.Module) + } + var hdl http.Handler = route.HandlerFunc - if route.RequiresEditorsAuth { + switch { + case route.RequiresEditorsAuth: hdl = botEditorAuthMiddleware(hdl) + case route.RequiresWriteAuth: + hdl = writeAuthMiddleware(hdl, route.Module) } if route.IsPrefix { diff --git a/action_counter.go b/action_counter.go index 5f194b0..49db9ab 100644 --- a/action_counter.go +++ b/action_counter.go @@ -95,6 +95,7 @@ func init() { Type: "int64", }, }, + RequiresWriteAuth: true, RouteParams: []plugins.HTTPRouteParamDocumentation{ { Description: "Name of the counter to update", diff --git a/action_setvar.go b/action_setvar.go index a2f851c..f7fbfba 100644 --- a/action_setvar.go +++ b/action_setvar.go @@ -80,6 +80,7 @@ func init() { Type: "string", }, }, + RequiresWriteAuth: true, RouteParams: []plugins.HTTPRouteParamDocumentation{ { Description: "Name of the variable to update", diff --git a/config.go b/config.go index fbe909e..ba5d877 100644 --- a/config.go +++ b/config.go @@ -46,21 +46,29 @@ func registerConfigReloadHook(hook func()) func() { } 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 { ConfigVersion int64 `yaml:"config_version"` } configFile struct { - AutoMessages []*autoMessage `yaml:"auto_messages"` - BotEditors []string `yaml:"bot_editors"` - Channels []string `yaml:"channels"` - GitTrackConfig bool `yaml:"git_track_config"` - HTTPListen string `yaml:"http_listen"` - PermitAllowModerator bool `yaml:"permit_allow_moderator"` - PermitTimeout time.Duration `yaml:"permit_timeout"` - RawLog string `yaml:"raw_log"` - Rules []*plugins.Rule `yaml:"rules"` - Variables map[string]interface{} `yaml:"variables"` + AuthTokens map[string]configAuthToken `yaml:"auth_tokens"` + AutoMessages []*autoMessage `yaml:"auto_messages"` + BotEditors []string `yaml:"bot_editors"` + Channels []string `yaml:"channels"` + GitTrackConfig bool `yaml:"git_track_config"` + HTTPListen string `yaml:"http_listen"` + PermitAllowModerator bool `yaml:"permit_allow_moderator"` + PermitTimeout time.Duration `yaml:"permit_timeout"` + RawLog string `yaml:"raw_log"` + Rules []*plugins.Rule `yaml:"rules"` + Variables map[string]interface{} `yaml:"variables"` rawLogWriter io.WriteCloser @@ -70,6 +78,7 @@ type ( func newConfigFile() *configFile { return &configFile{ + AuthTokens: map[string]configAuthToken{}, PermitTimeout: time.Minute, } } diff --git a/configEditor_general.go b/configEditor_general.go index 0b2cc07..3f8c81e 100644 --- a/configEditor_general.go +++ b/configEditor_general.go @@ -5,6 +5,8 @@ import ( "net/http" "github.com/Luzifer/twitch-bot/plugins" + "github.com/gofrs/uuid/v3" + "github.com/gorilla/mux" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) @@ -18,6 +20,44 @@ type ( func registerEditorGeneralConfigRoutes() { 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", 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) { if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{ 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) { user, _, err := getAuthorizationFromRequest(r) if err != nil { diff --git a/configEditor_global.go b/configEditor_global.go index c18686e..267c54c 100644 --- a/configEditor_global.go +++ b/configEditor_global.go @@ -21,6 +21,15 @@ func registerEditorGlobalMethods() { Path: "/actions", 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", 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) { usr, err := twitchClient.GetUserInformation(r.FormValue("user")) if err != nil { diff --git a/editor/app.js b/editor/app.js index 11eb893..41ccbb9 100644 --- a/editor/app.js +++ b/editor/app.js @@ -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() { return { headers: { @@ -95,6 +104,10 @@ new Vue({ }) }, + validateAPIToken() { + return this.models.apiToken.modules.length > 0 && Boolean(this.models.apiToken.name) + }, + validateAutoMessage() { if (!this.models.autoMessage.sendMode) { return false @@ -183,6 +196,7 @@ new Vue({ data: { actions: [], + apiTokens: {}, authToken: null, autoMessageFields: [ { @@ -221,6 +235,7 @@ new Vue({ configNotifySocket: null, configNotifySocketConnected: false, configNotifyBackoff: 100, + createdAPIToken: null, editMode: 'general', error: null, generalConfig: {}, @@ -228,10 +243,12 @@ new Vue({ addAction: '', addChannel: '', addEditor: '', + apiToken: {}, autoMessage: {}, rule: {}, }, + modules: [], rules: [], rulesFields: [ { @@ -254,6 +271,7 @@ new Vue({ }, ], + showAPITokenEditModal: false, showAutoMessageEditModal: false, showRuleEditModal: false, userProfiles: {}, @@ -344,6 +362,14 @@ new Vue({ .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() { return axios.get('config-editor/auto-messages', this.axiosOptions) .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) { return axios.get(`config-editor/user?user=${user}`, this.axiosOptions) .then(resp => Vue.set(this.userProfiles, user, resp.data)) @@ -500,6 +534,14 @@ new Vue({ Vue.set(this.models.rule, 'actions', tmp) }, + newAPIToken() { + Vue.set(this.models, 'apiToken', { + name: '', + modules: [], + }) + this.showAPITokenEditModal = true + }, + newAutoMessage() { Vue.set(this.models, 'autoMessage', {}) this.showAutoMessageEditModal = true @@ -545,6 +587,7 @@ new Vue({ reload() { return Promise.all([ + this.fetchAPITokens(), this.fetchAutoMessages(), this.fetchGeneralConfig(), this.fetchRules(), @@ -557,6 +600,14 @@ new Vue({ 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) { this.generalConfig.channels = this.generalConfig.channels .filter(ch => ch !== channel) @@ -571,9 +622,27 @@ new Vue({ 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) { if (!this.validateAutoMessage) { evt.preventDefault() + return } const obj = { ...this.models.autoMessage } @@ -602,6 +671,7 @@ new Vue({ saveRule(evt) { if (!this.validateRule) { evt.preventDefault() + return } const obj = { @@ -786,6 +856,7 @@ new Vue({ mounted() { this.fetchVars() this.fetchActions() + this.fetchModules() const params = new URLSearchParams(window.location.hash.substring(1)) this.authToken = params.get('access_token') || null diff --git a/editor/index.html b/editor/index.html index 18195d6..c7e3a42 100644 --- a/editor/index.html +++ b/editor/index.html @@ -179,6 +179,47 @@ + + + Auth-Tokens + + + + + + + Token was created, copy it within 30s as you will not see it again:
+ {{ createdAPIToken.token }} +
+ + + + {{ token.name }}
+ {{ module === '*' ? 'ANY' : module }} +
+ + + +
+
+
+ @@ -267,6 +308,43 @@ + + + + + + + + + + + [...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 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 func main() { var err error @@ -105,14 +155,8 @@ func main() { log.WithError(err).Fatal("Unable to load plugins") } - if len(rconfig.Args()) == 2 && rconfig.Args()[1] == "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") - } + if len(rconfig.Args()) > 1 { + handleSubCommand(rconfig.Args()[1:]) return } diff --git a/plugins/http_api.go b/plugins/http_api.go index 46a05f8..ef0bcd8 100644 --- a/plugins/http_api.go +++ b/plugins/http_api.go @@ -21,6 +21,7 @@ type ( Path string QueryParams []HTTPRouteParamDocumentation RequiresEditorsAuth bool + RequiresWriteAuth bool ResponseType HTTPRouteResponseType RouteParams []HTTPRouteParamDocumentation SkipDocumentation bool diff --git a/swagger.go b/swagger.go index a84bb67..072bc4b 100644 --- a/swagger.go +++ b/swagger.go @@ -31,9 +31,6 @@ var ( "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"), }, - SecuritySchemes: map[string]*spec.SecurityScheme{ - "authenticated": spec.APIKeyAuth("Authorization", spec.InHeader), - }, }, } @@ -41,6 +38,19 @@ var ( 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) { 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{ - {"authenticated": {}}, + {"configEditor": {}}, + } + + case route.RequiresWriteAuth: + op.Security = []map[string]spec.SecurityRequirement{ + {"writeAuth": {}}, } } diff --git a/wiki/Home.md b/wiki/Home.md index 1b841f6..4936635 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -8,6 +8,18 @@ # upgrade. 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 # 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. diff --git a/writeAuth.go b/writeAuth.go new file mode 100644 index 0000000..29a59c6 --- /dev/null +++ b/writeAuth.go @@ -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) + }) +}