mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-09 08:40:01 +00:00
[core] Add auth-cache for token auth
to speed up frontend and reduce CPU/MEM consumption on consecutive API requests Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
3c158ef231
commit
ee5e7359a2
7 changed files with 305 additions and 105 deletions
64
authBackends.go
Normal file
64
authBackends.go
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Luzifer/go_helpers/v2/str"
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/internal/service/authcache"
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const internalTokenAuthCacheExpiry = 5 * time.Minute
|
||||||
|
|
||||||
|
func authBackendInternalToken(token string) (modules []string, expiresAt time.Time, err error) {
|
||||||
|
for _, auth := range config.AuthTokens {
|
||||||
|
if auth.validate(token) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found a matching token
|
||||||
|
return auth.Modules, time.Now().Add(internalTokenAuthCacheExpiry), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, time.Time{}, authcache.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
func authBackendTwitchToken(token string) (modules []string, expiresAt time.Time, err error) {
|
||||||
|
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
|
||||||
|
|
||||||
|
var httpError twitch.HTTPError
|
||||||
|
|
||||||
|
id, user, err := tc.GetAuthorizedUser()
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
// We got a valid user, continue check below
|
||||||
|
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
|
||||||
|
// That user is none of our editors: Deny access
|
||||||
|
return nil, time.Time{}, authcache.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, expiresAt, err = tc.GetTokenInfo(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, errors.Wrap(err, "getting token expiry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editors have full access: Return module "*"
|
||||||
|
return []string{"*"}, expiresAt, nil
|
||||||
|
|
||||||
|
case errors.As(err, &httpError):
|
||||||
|
// We either got "forbidden" or we got another error
|
||||||
|
if httpError.Code == http.StatusUnauthorized {
|
||||||
|
// That token wasn't valid or not a Twitch token: Unauthorized
|
||||||
|
return nil, time.Time{}, authcache.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, time.Time{}, errors.Wrap(err, "validating Twitch token")
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Something else went wrong
|
||||||
|
return nil, time.Time{}, errors.Wrap(err, "validating Twitch token")
|
||||||
|
}
|
||||||
|
}
|
60
authMiddleware.go
Normal file
60
authMiddleware.go
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid/v3"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OWASP recommendations - 2023-07-07
|
||||||
|
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
||||||
|
argonFmt = "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s"
|
||||||
|
argonHashLen = 16
|
||||||
|
argonMemory = 46 * 1024
|
||||||
|
argonSaltLength = 8
|
||||||
|
argonThreads = 1
|
||||||
|
argonTime = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
func fillAuthToken(token *configAuthToken) error {
|
||||||
|
token.Token = uuid.Must(uuid.NewV4()).String()
|
||||||
|
|
||||||
|
salt := make([]byte, argonSaltLength)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return errors.Wrap(err, "reading salt")
|
||||||
|
}
|
||||||
|
|
||||||
|
token.Hash = fmt.Sprintf(
|
||||||
|
argonFmt,
|
||||||
|
argon2.Version,
|
||||||
|
argonMemory, argonTime, argonThreads,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(token.Token), salt, argonTime, argonMemory, argonThreads, argonHashLen)),
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
err := authService.ValidateTokenFor(token, module)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "auth not successful", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
140
internal/service/authcache/authcache.go
Normal file
140
internal/service/authcache/authcache.go
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
// Package authcache implements a cache for token auth to hold auth-
|
||||||
|
// results with cpu/mem inexpensive methods instead of always using
|
||||||
|
// secure but expensive methods to validate the token
|
||||||
|
package authcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Luzifer/go_helpers/v2/str"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const NegativeCacheTime = 5 * time.Minute
|
||||||
|
|
||||||
|
type (
|
||||||
|
Service struct {
|
||||||
|
backends []AuthFunc
|
||||||
|
cache map[string]*CacheEntry
|
||||||
|
lock sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
CacheEntry struct {
|
||||||
|
AuthResult error // Allows for negative caching
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Modules []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthFunc is a backend-function to resolve a token to a list of
|
||||||
|
// modules the token is authorized for, an expiry-time and an error.
|
||||||
|
// The error MUST be ErrUnauthorized in case the user is not found,
|
||||||
|
// if the error is another, the backend resolve will be cancelled
|
||||||
|
// and no further backends are queried.
|
||||||
|
AuthFunc func(token string) (modules []string, expiresAt time.Time, err error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrUnauthorized denotes the token could not be found in any backend
|
||||||
|
// auth method and therefore is not an user
|
||||||
|
var ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
|
||||||
|
func New(backends ...AuthFunc) *Service {
|
||||||
|
s := &Service{
|
||||||
|
backends: backends,
|
||||||
|
cache: make(map[string]*CacheEntry),
|
||||||
|
}
|
||||||
|
go s.runCleanup()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateTokenFor(token string, modules ...string) error {
|
||||||
|
s.lock.RLock()
|
||||||
|
cached := s.cache[s.cacheKey(token)]
|
||||||
|
s.lock.RUnlock()
|
||||||
|
|
||||||
|
if cached != nil && cached.ExpiresAt.After(time.Now()) {
|
||||||
|
// We do have a recent cache entry for that token: continue to use
|
||||||
|
return cached.validateFor(modules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No recent cache entry: We need to ask the expensive backends
|
||||||
|
var ce CacheEntry
|
||||||
|
backendLoop:
|
||||||
|
for _, fn := range s.backends {
|
||||||
|
ce.Modules, ce.ExpiresAt, ce.AuthResult = fn(token)
|
||||||
|
switch {
|
||||||
|
case ce.AuthResult == nil:
|
||||||
|
// Valid result & auth, the user was found
|
||||||
|
break backendLoop
|
||||||
|
|
||||||
|
case errors.Is(ce.AuthResult, ErrUnauthorized):
|
||||||
|
// Valid result, user was not found
|
||||||
|
continue backendLoop
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Something went wrong, bail out and do not cache
|
||||||
|
return errors.Wrap(ce.AuthResult, "querying authorization in backend")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We got a final result: That might be ErrUnauthorized or a valid
|
||||||
|
// user. Both should be cached. The error for a static time, the
|
||||||
|
// valid result for the time given by the backend.
|
||||||
|
if errors.Is(ce.AuthResult, ErrUnauthorized) {
|
||||||
|
ce.ExpiresAt = time.Now().Add(NegativeCacheTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lock.Lock()
|
||||||
|
s.cache[s.cacheKey(token)] = &ce
|
||||||
|
s.lock.Unlock()
|
||||||
|
|
||||||
|
// Finally return the result for the requested modules
|
||||||
|
return ce.validateFor(modules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Service) cacheKey(token string) string {
|
||||||
|
return fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(token)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) cleanup() {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
|
||||||
|
var (
|
||||||
|
now = time.Now()
|
||||||
|
remove []string
|
||||||
|
)
|
||||||
|
|
||||||
|
for key := range s.cache {
|
||||||
|
if s.cache[key].ExpiresAt.Before(now) {
|
||||||
|
remove = append(remove, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range remove {
|
||||||
|
delete(s.cache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) runCleanup() {
|
||||||
|
for range time.NewTicker(time.Minute).C {
|
||||||
|
s.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CacheEntry) validateFor(modules []string) error {
|
||||||
|
if c.AuthResult != nil {
|
||||||
|
return c.AuthResult
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reqMod := range modules {
|
||||||
|
if !str.StringInSlice(reqMod, c.Modules) && !str.StringInSlice("*", c.Modules) {
|
||||||
|
return errors.New("missing module in auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
7
main.go
7
main.go
|
@ -23,6 +23,7 @@ import (
|
||||||
"github.com/Luzifer/rconfig/v2"
|
"github.com/Luzifer/rconfig/v2"
|
||||||
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
|
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
|
||||||
"github.com/Luzifer/twitch-bot/v3/internal/service/access"
|
"github.com/Luzifer/twitch-bot/v3/internal/service/access"
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/internal/service/authcache"
|
||||||
"github.com/Luzifer/twitch-bot/v3/internal/service/timer"
|
"github.com/Luzifer/twitch-bot/v3/internal/service/timer"
|
||||||
"github.com/Luzifer/twitch-bot/v3/pkg/database"
|
"github.com/Luzifer/twitch-bot/v3/pkg/database"
|
||||||
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
||||||
|
@ -69,6 +70,7 @@ var (
|
||||||
|
|
||||||
db database.Connector
|
db database.Connector
|
||||||
accessService *access.Service
|
accessService *access.Service
|
||||||
|
authService *authcache.Service
|
||||||
timerService *timer.Service
|
timerService *timer.Service
|
||||||
|
|
||||||
twitchClient *twitch.Client
|
twitchClient *twitch.Client
|
||||||
|
@ -136,6 +138,11 @@ func main() {
|
||||||
log.WithError(err).Fatal("applying access migration")
|
log.WithError(err).Fatal("applying access migration")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authService = authcache.New(
|
||||||
|
authBackendInternalToken,
|
||||||
|
authBackendTwitchToken,
|
||||||
|
)
|
||||||
|
|
||||||
cronService = cron.New(cron.WithSeconds())
|
cronService = cron.New(cron.WithSeconds())
|
||||||
|
|
||||||
if timerService, err = timer.New(db, cronService); err != nil {
|
if timerService, err = timer.New(db, cronService); err != nil {
|
||||||
|
|
33
pkg/twitch/auth.go
Normal file
33
pkg/twitch/auth.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
package twitch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTokenInfo requests a validation for the token set within the
|
||||||
|
// client and returns the authorized user, their granted scopes on this
|
||||||
|
// token and an error in case something went wrong.
|
||||||
|
func (c *Client) GetTokenInfo(ctx context.Context) (user string, scopes []string, expiresAt time.Time, err error) {
|
||||||
|
var payload OAuthTokenValidationResponse
|
||||||
|
|
||||||
|
if c.accessToken == "" {
|
||||||
|
return "", nil, time.Time{}, errors.New("no access token present")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Request(ClientRequestOpts{
|
||||||
|
AuthType: AuthTypeBearerToken,
|
||||||
|
Context: ctx,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
OKStatus: http.StatusOK,
|
||||||
|
Out: &payload,
|
||||||
|
URL: "https://id.twitch.tv/oauth2/validate",
|
||||||
|
}); err != nil {
|
||||||
|
return "", nil, time.Time{}, errors.Wrap(err, "validating token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.Login, payload.Scopes, time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second), nil
|
||||||
|
}
|
|
@ -169,7 +169,7 @@ func getRegistrationArguments() plugins.RegistrationArguments {
|
||||||
RegisterRawMessageHandler: registerRawMessageHandler,
|
RegisterRawMessageHandler: registerRawMessageHandler,
|
||||||
RegisterTemplateFunction: tplFuncs.Register,
|
RegisterTemplateFunction: tplFuncs.Register,
|
||||||
SendMessage: sendMessage,
|
SendMessage: sendMessage,
|
||||||
ValidateToken: validateAuthToken,
|
ValidateToken: authService.ValidateTokenFor,
|
||||||
|
|
||||||
CreateEvent: func(evt string, eventData *plugins.FieldCollection) error {
|
CreateEvent: func(evt string, eventData *plugins.FieldCollection) error {
|
||||||
handleMessage(ircHdl.Client(), nil, &evt, eventData)
|
handleMessage(ircHdl.Client(), nil, &evt, eventData)
|
||||||
|
|
104
writeAuth.go
104
writeAuth.go
|
@ -1,104 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gofrs/uuid/v3"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"golang.org/x/crypto/argon2"
|
|
||||||
|
|
||||||
"github.com/Luzifer/go_helpers/v2/str"
|
|
||||||
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// OWASP recommendations - 2023-07-07
|
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
|
||||||
argonFmt = "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s"
|
|
||||||
argonHashLen = 16
|
|
||||||
argonMemory = 46 * 1024
|
|
||||||
argonSaltLength = 8
|
|
||||||
argonThreads = 1
|
|
||||||
argonTime = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
func fillAuthToken(token *configAuthToken) error {
|
|
||||||
token.Token = uuid.Must(uuid.NewV4()).String()
|
|
||||||
|
|
||||||
salt := make([]byte, argonSaltLength)
|
|
||||||
if _, err := rand.Read(salt); err != nil {
|
|
||||||
return errors.Wrap(err, "reading salt")
|
|
||||||
}
|
|
||||||
|
|
||||||
token.Hash = fmt.Sprintf(
|
|
||||||
argonFmt,
|
|
||||||
argon2.Version,
|
|
||||||
argonMemory, argonTime, argonThreads,
|
|
||||||
base64.RawStdEncoding.EncodeToString(salt),
|
|
||||||
base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(token.Token), salt, argonTime, argonMemory, argonThreads, argonHashLen)),
|
|
||||||
)
|
|
||||||
|
|
||||||
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 _, fn := range []func() error{
|
|
||||||
// First try to validate against internal token management
|
|
||||||
func() error { return validateAuthToken(token, module) },
|
|
||||||
// If not successful validate against Twitch and check for bot-editors
|
|
||||||
func() error { return validateTwitchBotEditorAuthToken(token) },
|
|
||||||
} {
|
|
||||||
if err := fn(); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Error(w, "auth not successful", http.StatusForbidden)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAuthToken(token string, modules ...string) error {
|
|
||||||
for _, auth := range config.AuthTokens {
|
|
||||||
if auth.validate(token) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, reqMod := range modules {
|
|
||||||
if !str.StringInSlice(reqMod, auth.Modules) && !str.StringInSlice("*", auth.Modules) {
|
|
||||||
return errors.New("missing module in auth")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil // We found a matching token and it has all required tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.New("no matching token")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateTwitchBotEditorAuthToken(token string) error {
|
|
||||||
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
|
|
||||||
|
|
||||||
id, user, err := tc.GetAuthorizedUser()
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting authorized user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) {
|
|
||||||
return errors.New("user is not an bot-edtior")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
Loading…
Reference in a new issue