From 4f00beefd072c55ab81fd232a78049c6b42b4434 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sun, 16 Jun 2024 17:43:37 +0200 Subject: [PATCH] Allow to issue generic tokens with variable expiry Signed-off-by: Knut Ahlers --- authBackends.go | 11 +-- botEditor.go | 16 ++-- configEditor_automessage.go | 6 +- configEditor_general.go | 6 +- configEditor_global.go | 7 +- configEditor_rules.go | 6 +- internal/service/authcache/authcache.go | 6 ++ internal/service/editortoken/editortoken.go | 78 ++++++++++++++----- .../service/editortoken/editortoken_test.go | 27 ++++++- 9 files changed, 114 insertions(+), 49 deletions(-) diff --git a/authBackends.go b/authBackends.go index b0eec52..f983bb0 100644 --- a/authBackends.go +++ b/authBackends.go @@ -3,7 +3,6 @@ package main import ( "time" - "github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/twitch-bot/v3/internal/service/authcache" ) @@ -23,17 +22,11 @@ func authBackendInternalAppToken(token string) (modules []string, expiresAt time } func authBackendInternalEditorToken(token string) ([]string, time.Time, error) { - id, user, expiresAt, err := editorTokenService.ValidateLoginToken(token) + _, _, expiresAt, modules, err := editorTokenService.ValidateLoginToken(token) if err != nil { // None of our tokens: Nay. return nil, time.Time{}, authcache.ErrUnauthorized } - 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 - } - - // Editors have full access: Return module "*" - return []string{"*"}, expiresAt, nil + return modules, expiresAt, nil } diff --git a/botEditor.go b/botEditor.go index 83e0931..f2e210b 100644 --- a/botEditor.go +++ b/botEditor.go @@ -1,21 +1,23 @@ package main import ( + "fmt" "net/http" "github.com/pkg/errors" - - "github.com/Luzifer/twitch-bot/v3/pkg/twitch" ) -func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error) { +func getAuthorizationFromRequest(r *http.Request) (string, error) { token := r.Header.Get("Authorization") if token == "" { - return "", nil, errors.New("no authorization provided") + return "", fmt.Errorf("no authorization provided") } - tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "") + _, user, _, _, err := editorTokenService.ValidateLoginToken(token) //nolint:dogsled // Required at other places - _, user, err := tc.GetAuthorizedUser(r.Context()) - return user, tc, errors.Wrap(err, "getting authorized user") + if user == "" { + user = "API-User" + } + + return user, errors.Wrap(err, "getting authorized user") } diff --git a/configEditor_automessage.go b/configEditor_automessage.go index d75e2f1..9d7766a 100644 --- a/configEditor_automessage.go +++ b/configEditor_automessage.go @@ -78,7 +78,7 @@ func registerEditorAutoMessageRoutes() { } func configEditorHandleAutoMessageAdd(w http.ResponseWriter, r *http.Request) { - user, _, err := getAuthorizationFromRequest(r) + user, err := getAuthorizationFromRequest(r) if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return @@ -104,7 +104,7 @@ func configEditorHandleAutoMessageAdd(w http.ResponseWriter, r *http.Request) { } func configEditorHandleAutoMessageDelete(w http.ResponseWriter, r *http.Request) { - user, _, err := getAuthorizationFromRequest(r) + user, err := getAuthorizationFromRequest(r) if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return @@ -141,7 +141,7 @@ func configEditorHandleAutoMessagesGet(w http.ResponseWriter, _ *http.Request) { } func configEditorHandleAutoMessageUpdate(w http.ResponseWriter, r *http.Request) { - user, _, err := getAuthorizationFromRequest(r) + user, err := getAuthorizationFromRequest(r) if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return diff --git a/configEditor_general.go b/configEditor_general.go index a29bcc0..14aca92 100644 --- a/configEditor_general.go +++ b/configEditor_general.go @@ -103,7 +103,7 @@ func registerEditorGeneralConfigRoutes() { } func configEditorHandleGeneralAddAuthToken(w http.ResponseWriter, r *http.Request) { - user, _, err := getAuthorizationFromRequest(r) + user, err := getAuthorizationFromRequest(r) if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return @@ -169,7 +169,7 @@ func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, _ *http.Request) { } func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Request) { - user, _, err := getAuthorizationFromRequest(r) + user, err := getAuthorizationFromRequest(r) if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return @@ -232,7 +232,7 @@ func configEditorHandleGeneralListAuthTokens(w http.ResponseWriter, _ *http.Requ } func configEditorHandleGeneralUpdate(w http.ResponseWriter, r *http.Request) { - user, _, err := getAuthorizationFromRequest(r) + user, err := getAuthorizationFromRequest(r) if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return diff --git a/configEditor_global.go b/configEditor_global.go index 0433a17..e24c1df 100644 --- a/configEditor_global.go +++ b/configEditor_global.go @@ -207,7 +207,8 @@ func configEditorGlobalLogin(w http.ResponseWriter, r *http.Request) { return } - tok, expiresAt, err := editorTokenService.CreateLoginToken(id, user) + // Bot-Editors do have unlimited access to all modules: Pass in module `*` + tok, expiresAt, err := editorTokenService.CreateUserToken(id, user, []string{"*"}) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -228,12 +229,12 @@ func configEditorGlobalRefreshToken(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid renew request", http.StatusBadRequest) } - id, user, _, err := editorTokenService.ValidateLoginToken(token) + id, user, _, modules, err := editorTokenService.ValidateLoginToken(token) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } - tok, expiresAt, err := editorTokenService.CreateLoginToken(id, user) + tok, expiresAt, err := editorTokenService.CreateUserToken(id, user, modules) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } diff --git a/configEditor_rules.go b/configEditor_rules.go index 43208cf..a82966a 100644 --- a/configEditor_rules.go +++ b/configEditor_rules.go @@ -78,7 +78,7 @@ func registerEditorRulesRoutes() { } func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) { - user, _, err := getAuthorizationFromRequest(r) + user, err := getAuthorizationFromRequest(r) if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return @@ -117,7 +117,7 @@ func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) { } func configEditorRulesDelete(w http.ResponseWriter, r *http.Request) { - user, _, err := getAuthorizationFromRequest(r) + user, err := getAuthorizationFromRequest(r) if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return @@ -154,7 +154,7 @@ func configEditorRulesGet(w http.ResponseWriter, _ *http.Request) { } func configEditorRulesUpdate(w http.ResponseWriter, r *http.Request) { - user, _, err := getAuthorizationFromRequest(r) + user, err := getAuthorizationFromRequest(r) if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return diff --git a/internal/service/authcache/authcache.go b/internal/service/authcache/authcache.go index 3a2c777..46fa469 100644 --- a/internal/service/authcache/authcache.go +++ b/internal/service/authcache/authcache.go @@ -93,6 +93,12 @@ backendLoop: ce.ExpiresAt = time.Now().Add(negativeCacheTime) } + if ce.ExpiresAt.IsZero() { + // Infinite valid token, we should periodically re-check and + // therefore cache for the negativeCacheTime + ce.ExpiresAt = time.Now().Add(negativeCacheTime) + } + s.lock.Lock() s.cache[s.cacheKey(token)] = &ce s.lock.Unlock() diff --git a/internal/service/editortoken/editortoken.go b/internal/service/editortoken/editortoken.go index 1038ae0..52fe633 100644 --- a/internal/service/editortoken/editortoken.go +++ b/internal/service/editortoken/editortoken.go @@ -20,14 +20,15 @@ const ( type ( claims struct { - TwitchUser twitchUser `json:"twitchUser"` + Modules []string `json:"modules"` + TwitchUser *twitchUser `json:"twitchUser,omitempty"` jwt.RegisteredClaims } twitchUser struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` } // Service manages the permission database @@ -39,11 +40,12 @@ func New(db database.Connector) *Service { return &Service{db} } -// CreateLoginToken packs user-id and user name into a JWT, signs it +// CreateUserToken packs user-id and user name into a JWT, signs it // and returns the signed token -func (s Service) CreateLoginToken(id, user string) (token string, expiresAt time.Time, err error) { +func (s Service) CreateUserToken(id, user string, modules []string) (token string, expiresAt time.Time, err error) { cl := claims{ - TwitchUser: twitchUser{ + Modules: modules, + TwitchUser: &twitchUser{ ID: id, Name: user, }, @@ -57,23 +59,40 @@ func (s Service) CreateLoginToken(id, user string) (token string, expiresAt time }, } - tok := jwt.NewWithClaims(&jwt.SigningMethodEd25519{}, cl) - - priv, err := s.getSigningKey() - if err != nil { - return "", expiresAt, fmt.Errorf("getting signing key: %w", err) - } - - if token, err = tok.SignedString(priv); err != nil { - return "", expiresAt, fmt.Errorf("signing token: %w", err) + if token, err = s.createTokenFromClaims(cl); err != nil { + return "", time.Time{}, fmt.Errorf("creating token: %w", err) } return token, cl.ExpiresAt.Time, nil } +// CreateGenericModuleToken creates a non-user-bound token with the +// given modules. Pass in 0 validity to create a non-expiring token. +func (s Service) CreateGenericModuleToken(modules []string, validity time.Duration) (token string, err error) { + cl := claims{ + Modules: modules, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "Twitch-Bot", + Audience: []string{}, + NotBefore: jwt.NewNumericDate(time.Now()), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + if validity > 0 { + cl.ExpiresAt = jwt.NewNumericDate(time.Now().Add(validity)) + } + + if token, err = s.createTokenFromClaims(cl); err != nil { + return "", fmt.Errorf("creating token: %w", err) + } + + return token, nil +} + // ValidateLoginToken takes a token, validates it with the stored // key and returns the twitch-id and the user-name from the token -func (s Service) ValidateLoginToken(token string) (id, user string, expiresAt time.Time, err error) { +func (s Service) ValidateLoginToken(token string) (id, user string, expiresAt time.Time, modules []string, err error) { var cl claims tok, err := jwt.ParseWithClaims(token, &cl, func(*jwt.Token) (any, error) { @@ -86,16 +105,37 @@ func (s Service) ValidateLoginToken(token string) (id, user string, expiresAt ti }) if err != nil { // Something went wrong when parsing & validating - return "", "", expiresAt, fmt.Errorf("validating token: %w", err) + return "", "", expiresAt, nil, fmt.Errorf("validating token: %w", err) } if claims, ok := tok.Claims.(*claims); ok { + if claims.ExpiresAt != nil { + expiresAt = claims.ExpiresAt.Time + } + + if claims.TwitchUser == nil { + return "", "", expiresAt, claims.Modules, nil + } // We had no error and the claims are our claims - return claims.TwitchUser.ID, claims.TwitchUser.Name, claims.ExpiresAt.Time, nil + return claims.TwitchUser.ID, claims.TwitchUser.Name, expiresAt, claims.Modules, nil } // We had no error but were not able to convert the claims - return "", "", expiresAt, fmt.Errorf("unknown claims type") + return "", "", expiresAt, nil, fmt.Errorf("unknown claims type") +} + +func (s Service) createTokenFromClaims(cl claims) (token string, err error) { + tok := jwt.NewWithClaims(&jwt.SigningMethodEd25519{}, cl) + + priv, err := s.getSigningKey() + if err != nil { + return "", fmt.Errorf("getting signing key: %w", err) + } + + if token, err = tok.SignedString(priv); err != nil { + return "", fmt.Errorf("signing token: %w", err) + } + return token, nil } func (s Service) getSigningKey() (priv ed25519.PrivateKey, err error) { diff --git a/internal/service/editortoken/editortoken_test.go b/internal/service/editortoken/editortoken_test.go index 95debbd..3a4077b 100644 --- a/internal/service/editortoken/editortoken_test.go +++ b/internal/service/editortoken/editortoken_test.go @@ -43,13 +43,36 @@ func TestTokenFlow(t *testing.T) { user = "example" ) - tok, expiresAt, err := s.CreateLoginToken(id, user) + tok, expiresAt, err := s.CreateUserToken(id, user, []string{"*"}) require.NoError(t, err) assert.True(t, expiresAt.After(time.Now().Add(tokenValidity-time.Minute))) - tid, tuser, texpiresAt, err := s.ValidateLoginToken(tok) + tid, tuser, texpiresAt, modules, err := s.ValidateLoginToken(tok) require.NoError(t, err) assert.Equal(t, id, tid) assert.Equal(t, user, tuser) assert.Equal(t, expiresAt, texpiresAt) + assert.Equal(t, []string{"*"}, modules) + + // Generic without expiry + tok, err = s.CreateGenericModuleToken([]string{"test"}, 0) + require.NoError(t, err) + + tid, tuser, texpiresAt, modules, err = s.ValidateLoginToken(tok) + require.NoError(t, err) + assert.Equal(t, "", tid) + assert.Equal(t, "", tuser) + assert.Equal(t, time.Time{}, texpiresAt) + assert.Equal(t, []string{"test"}, modules) + + // Generic with expiry + tok, err = s.CreateGenericModuleToken([]string{"test"}, time.Minute) + require.NoError(t, err) + + tid, tuser, texpiresAt, modules, err = s.ValidateLoginToken(tok) + require.NoError(t, err) + assert.Equal(t, "", tid) + assert.Equal(t, "", tuser) + assert.True(t, time.Now().Add(time.Minute+time.Second).After(texpiresAt)) + assert.Equal(t, []string{"test"}, modules) }