mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-09 16:50:01 +00:00
[core] Implement write authorization for APIs (#9)
This commit is contained in:
parent
ed15c532d3
commit
77aa2e411c
15 changed files with 485 additions and 64 deletions
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
29
config.go
29
config.go
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
60
main.go
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
26
swagger.go
26
swagger.go
|
@ -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": {}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
12
wiki/Home.md
12
wiki/Home.md
|
@ -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
56
writeAuth.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in a new issue