From ee5e7359a24239d8bf4f7db71230a9585415d298 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Mon, 4 Dec 2023 14:12:01 +0100 Subject: [PATCH] [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 --- authBackends.go | 64 +++++++++++ authMiddleware.go | 60 ++++++++++ internal/service/authcache/authcache.go | 140 ++++++++++++++++++++++++ main.go | 7 ++ pkg/twitch/auth.go | 33 ++++++ plugins_core.go | 2 +- writeAuth.go | 104 ------------------ 7 files changed, 305 insertions(+), 105 deletions(-) create mode 100644 authBackends.go create mode 100644 authMiddleware.go create mode 100644 internal/service/authcache/authcache.go create mode 100644 pkg/twitch/auth.go delete mode 100644 writeAuth.go diff --git a/authBackends.go b/authBackends.go new file mode 100644 index 0000000..69216ad --- /dev/null +++ b/authBackends.go @@ -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") + } +} diff --git a/authMiddleware.go b/authMiddleware.go new file mode 100644 index 0000000..1bf9062 --- /dev/null +++ b/authMiddleware.go @@ -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) + }) +} diff --git a/internal/service/authcache/authcache.go b/internal/service/authcache/authcache.go new file mode 100644 index 0000000..91e3e95 --- /dev/null +++ b/internal/service/authcache/authcache.go @@ -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 +} diff --git a/main.go b/main.go index 3002edf..ffdd594 100644 --- a/main.go +++ b/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 { diff --git a/pkg/twitch/auth.go b/pkg/twitch/auth.go new file mode 100644 index 0000000..39211c2 --- /dev/null +++ b/pkg/twitch/auth.go @@ -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 +} diff --git a/plugins_core.go b/plugins_core.go index 58aefca..4b758ca 100644 --- a/plugins_core.go +++ b/plugins_core.go @@ -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) diff --git a/writeAuth.go b/writeAuth.go deleted file mode 100644 index b1da649..0000000 --- a/writeAuth.go +++ /dev/null @@ -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 -}