mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 03:41:16 +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/twitch-bot/v3/internal/helpers"
|
||||
"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/pkg/database"
|
||||
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
||||
|
@ -69,6 +70,7 @@ var (
|
|||
|
||||
db database.Connector
|
||||
accessService *access.Service
|
||||
authService *authcache.Service
|
||||
timerService *timer.Service
|
||||
|
||||
twitchClient *twitch.Client
|
||||
|
@ -136,6 +138,11 @@ func main() {
|
|||
log.WithError(err).Fatal("applying access migration")
|
||||
}
|
||||
|
||||
authService = authcache.New(
|
||||
authBackendInternalToken,
|
||||
authBackendTwitchToken,
|
||||
)
|
||||
|
||||
cronService = cron.New(cron.WithSeconds())
|
||||
|
||||
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,
|
||||
RegisterTemplateFunction: tplFuncs.Register,
|
||||
SendMessage: sendMessage,
|
||||
ValidateToken: validateAuthToken,
|
||||
ValidateToken: authService.ValidateTokenFor,
|
||||
|
||||
CreateEvent: func(evt string, eventData *plugins.FieldCollection) error {
|
||||
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