[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:
Knut Ahlers 2023-12-04 14:12:01 +01:00
parent 3c158ef231
commit ee5e7359a2
Signed by: luzifer
GPG key ID: D91C3E91E4CAD6F5
7 changed files with 305 additions and 105 deletions

64
authBackends.go Normal file
View 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
View 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)
})
}

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

View file

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

View file

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

View file

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