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 5818aafbc4
commit 63e8589a15
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 (
"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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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