mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 20:01:17 +00:00
Allow to issue generic tokens with variable expiry
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
f9198367a5
commit
4f00beefd0
9 changed files with 114 additions and 49 deletions
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
16
botEditor.go
16
botEditor.go
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue