Allow to issue generic tokens with variable expiry

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-06-16 17:43:37 +02:00
parent f9198367a5
commit 4f00beefd0
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
9 changed files with 114 additions and 49 deletions

View file

@ -3,7 +3,6 @@ package main
import ( import (
"time" "time"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/internal/service/authcache" "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) { func authBackendInternalEditorToken(token string) ([]string, time.Time, error) {
id, user, expiresAt, err := editorTokenService.ValidateLoginToken(token) _, _, expiresAt, modules, err := editorTokenService.ValidateLoginToken(token)
if err != nil { if err != nil {
// None of our tokens: Nay. // None of our tokens: Nay.
return nil, time.Time{}, authcache.ErrUnauthorized return nil, time.Time{}, authcache.ErrUnauthorized
} }
if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) { return modules, expiresAt, nil
// 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
} }

View file

@ -1,21 +1,23 @@
package main package main
import ( import (
"fmt"
"net/http" "net/http"
"github.com/pkg/errors" "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") token := r.Header.Get("Authorization")
if token == "" { 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()) if user == "" {
return user, tc, errors.Wrap(err, "getting authorized user") user = "API-User"
}
return user, errors.Wrap(err, "getting authorized user")
} }

View file

@ -78,7 +78,7 @@ func registerEditorAutoMessageRoutes() {
} }
func configEditorHandleAutoMessageAdd(w http.ResponseWriter, r *http.Request) { func configEditorHandleAutoMessageAdd(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r) user, err := getAuthorizationFromRequest(r)
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return
@ -104,7 +104,7 @@ func configEditorHandleAutoMessageAdd(w http.ResponseWriter, r *http.Request) {
} }
func configEditorHandleAutoMessageDelete(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 { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return
@ -141,7 +141,7 @@ func configEditorHandleAutoMessagesGet(w http.ResponseWriter, _ *http.Request) {
} }
func configEditorHandleAutoMessageUpdate(w http.ResponseWriter, r *http.Request) { func configEditorHandleAutoMessageUpdate(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r) user, err := getAuthorizationFromRequest(r)
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return

View file

@ -103,7 +103,7 @@ func registerEditorGeneralConfigRoutes() {
} }
func configEditorHandleGeneralAddAuthToken(w http.ResponseWriter, r *http.Request) { func configEditorHandleGeneralAddAuthToken(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r) user, err := getAuthorizationFromRequest(r)
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return
@ -169,7 +169,7 @@ func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, _ *http.Request) {
} }
func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Request) { func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r) user, err := getAuthorizationFromRequest(r)
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return
@ -232,7 +232,7 @@ func configEditorHandleGeneralListAuthTokens(w http.ResponseWriter, _ *http.Requ
} }
func configEditorHandleGeneralUpdate(w http.ResponseWriter, r *http.Request) { func configEditorHandleGeneralUpdate(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r) user, err := getAuthorizationFromRequest(r)
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return

View file

@ -207,7 +207,8 @@ func configEditorGlobalLogin(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -228,12 +229,12 @@ func configEditorGlobalRefreshToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid renew request", http.StatusBadRequest) http.Error(w, "invalid renew request", http.StatusBadRequest)
} }
id, user, _, err := editorTokenService.ValidateLoginToken(token) id, user, _, modules, err := editorTokenService.ValidateLoginToken(token)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }

View file

@ -78,7 +78,7 @@ func registerEditorRulesRoutes() {
} }
func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) { func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r) user, err := getAuthorizationFromRequest(r)
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return
@ -117,7 +117,7 @@ func configEditorRulesAdd(w http.ResponseWriter, r *http.Request) {
} }
func configEditorRulesDelete(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 { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return
@ -154,7 +154,7 @@ func configEditorRulesGet(w http.ResponseWriter, _ *http.Request) {
} }
func configEditorRulesUpdate(w http.ResponseWriter, r *http.Request) { func configEditorRulesUpdate(w http.ResponseWriter, r *http.Request) {
user, _, err := getAuthorizationFromRequest(r) user, err := getAuthorizationFromRequest(r)
if err != nil { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return

View file

@ -93,6 +93,12 @@ backendLoop:
ce.ExpiresAt = time.Now().Add(negativeCacheTime) 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.lock.Lock()
s.cache[s.cacheKey(token)] = &ce s.cache[s.cacheKey(token)] = &ce
s.lock.Unlock() s.lock.Unlock()

View file

@ -20,14 +20,15 @@ const (
type ( type (
claims struct { claims struct {
TwitchUser twitchUser `json:"twitchUser"` Modules []string `json:"modules"`
TwitchUser *twitchUser `json:"twitchUser,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
twitchUser struct { twitchUser struct {
ID string `json:"id"` ID string `json:"id,omitempty"`
Name string `json:"name"` Name string `json:"name,omitempty"`
} }
// Service manages the permission database // Service manages the permission database
@ -39,11 +40,12 @@ func New(db database.Connector) *Service {
return &Service{db} 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 // 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{ cl := claims{
TwitchUser: twitchUser{ Modules: modules,
TwitchUser: &twitchUser{
ID: id, ID: id,
Name: user, Name: user,
}, },
@ -57,23 +59,40 @@ func (s Service) CreateLoginToken(id, user string) (token string, expiresAt time
}, },
} }
tok := jwt.NewWithClaims(&jwt.SigningMethodEd25519{}, cl) if token, err = s.createTokenFromClaims(cl); err != nil {
return "", time.Time{}, fmt.Errorf("creating token: %w", err)
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)
} }
return token, cl.ExpiresAt.Time, nil 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 // ValidateLoginToken takes a token, validates it with the stored
// key and returns the twitch-id and the user-name from the token // 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 var cl claims
tok, err := jwt.ParseWithClaims(token, &cl, func(*jwt.Token) (any, error) { 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 { if err != nil {
// Something went wrong when parsing & validating // 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, 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 // 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 // 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) { func (s Service) getSigningKey() (priv ed25519.PrivateKey, err error) {

View file

@ -43,13 +43,36 @@ func TestTokenFlow(t *testing.T) {
user = "example" user = "example"
) )
tok, expiresAt, err := s.CreateLoginToken(id, user) tok, expiresAt, err := s.CreateUserToken(id, user, []string{"*"})
require.NoError(t, err) require.NoError(t, err)
assert.True(t, expiresAt.After(time.Now().Add(tokenValidity-time.Minute))) 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) require.NoError(t, err)
assert.Equal(t, id, tid) assert.Equal(t, id, tid)
assert.Equal(t, user, tuser) assert.Equal(t, user, tuser)
assert.Equal(t, expiresAt, texpiresAt) 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)
} }