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
|
||||
-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 <name> <scope...> Generate an api-token to be entered into the config
|
||||
help Prints this help message
|
||||
```
|
||||
|
|
|
@ -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,7 +21,8 @@ import (
|
|||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var coreActorRegistations = []plugins.RegisterFunc{
|
||||
var (
|
||||
coreActorRegistations = []plugins.RegisterFunc{
|
||||
ban.Register,
|
||||
delay.Register,
|
||||
deleteactor.Register,
|
||||
|
@ -32,6 +34,8 @@ var coreActorRegistations = []plugins.RegisterFunc{
|
|||
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 {
|
||||
|
|
|
@ -95,6 +95,7 @@ func init() {
|
|||
Type: "int64",
|
||||
},
|
||||
},
|
||||
RequiresWriteAuth: true,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
Description: "Name of the counter to update",
|
||||
|
|
|
@ -80,6 +80,7 @@ func init() {
|
|||
Type: "string",
|
||||
},
|
||||
},
|
||||
RequiresWriteAuth: true,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
Description: "Name of the variable to update",
|
||||
|
|
|
@ -46,11 +46,19 @@ 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 {
|
||||
AuthTokens map[string]configAuthToken `yaml:"auth_tokens"`
|
||||
AutoMessages []*autoMessage `yaml:"auto_messages"`
|
||||
BotEditors []string `yaml:"bot_editors"`
|
||||
Channels []string `yaml:"channels"`
|
||||
|
@ -70,6 +78,7 @@ type (
|
|||
|
||||
func newConfigFile() *configFile {
|
||||
return &configFile{
|
||||
AuthTokens: map[string]configAuthToken{},
|
||||
PermitTimeout: time.Minute,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -179,6 +179,47 @@
|
|||
</b-list-group>
|
||||
</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-col>
|
||||
</b-row>
|
||||
|
@ -267,6 +308,43 @@
|
|||
|
||||
</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 -->
|
||||
<b-modal
|
||||
@hidden="showAutoMessageEditModal=false"
|
||||
|
|
|
@ -35,6 +35,7 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
|
|||
Module: "quotedb",
|
||||
Name: "Add Quotes",
|
||||
Path: "/{channel}",
|
||||
RequiresWriteAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
|
@ -51,6 +52,7 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
|
|||
Module: "quotedb",
|
||||
Name: "Delete Quote",
|
||||
Path: "/{channel}/{idx:[0-9]+}",
|
||||
RequiresWriteAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
|
@ -88,6 +90,7 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
|
|||
Module: "quotedb",
|
||||
Name: "Set Quotes",
|
||||
Path: "/{channel}",
|
||||
RequiresWriteAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
|
@ -104,6 +107,7 @@ func registerAPI(register plugins.HTTPRouteRegistrationFunc) {
|
|||
Module: "quotedb",
|
||||
Name: "Update Quote",
|
||||
Path: "/{channel}/{idx:[0-9]+}",
|
||||
RequiresWriteAuth: true,
|
||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||
RouteParams: []plugins.HTTPRouteParamDocumentation{
|
||||
{
|
||||
|
|
60
main.go
60
main.go
|
@ -11,10 +11,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/go-irc/irc"
|
||||
"github.com/gofrs/uuid/v3"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/robfig/cron/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/Luzifer/go_helpers/v2/str"
|
||||
"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
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ type (
|
|||
Path string
|
||||
QueryParams []HTTPRouteParamDocumentation
|
||||
RequiresEditorsAuth bool
|
||||
RequiresWriteAuth bool
|
||||
ResponseType HTTPRouteResponseType
|
||||
RouteParams []HTTPRouteParamDocumentation
|
||||
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"),
|
||||
"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": {}},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
12
wiki/Home.md
12
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.
|
||||
|
|
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