[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
-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
```

View file

@ -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 {

View file

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

View file

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

View file

@ -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,
}
}

View file

@ -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 {

View file

@ -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 {

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() {
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

View file

@ -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"

View file

@ -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
View file

@ -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
}

View file

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

View file

@ -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": {}},
}
}

View file

@ -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
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)
})
}