Skip to content

Commit

Permalink
Allow to issue generic tokens with variable expiry
Browse files Browse the repository at this point in the history
Signed-off-by: Knut Ahlers <knut@ahlers.me>
  • Loading branch information
Luzifer committed Jun 16, 2024
1 parent 6abf681 commit e8c2c60
Show file tree
Hide file tree
Showing 9 changed files with 112 additions and 47 deletions.
11 changes: 2 additions & 9 deletions authBackends.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
"time"

"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/v3/internal/service/authcache"
)

Expand All @@ -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
}
16 changes: 9 additions & 7 deletions botEditor.go
Original file line number Diff line number Diff line change
@@ -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

if user == "" {
user = "API-User"
}

_, user, err := tc.GetAuthorizedUser(r.Context())
return user, tc, errors.Wrap(err, "getting authorized user")
return user, errors.Wrap(err, "getting authorized user")
}
6 changes: 3 additions & 3 deletions configEditor_automessage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -103,7 +103,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)
}
Expand Down Expand Up @@ -139,7 +139,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)
}
Expand Down
6 changes: 3 additions & 3 deletions configEditor_general.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -231,7 +231,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)
}
Expand Down
7 changes: 4 additions & 3 deletions configEditor_global.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions configEditor_rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -116,7 +116,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)
}
Expand Down Expand Up @@ -152,7 +152,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)
}
Expand Down
6 changes: 6 additions & 0 deletions internal/service/authcache/authcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
74 changes: 57 additions & 17 deletions internal/service/editortoken/editortoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
},
Expand All @@ -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)
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 token, err = tok.SignedString(priv); err != nil {
return "", expiresAt, fmt.Errorf("signing token: %w", err)
if validity > 0 {
cl.ExpiresAt = jwt.NewNumericDate(time.Now().Add(validity))
}

return token, cl.ExpiresAt.Time, nil
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) {
Expand All @@ -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) {
Expand Down
27 changes: 25 additions & 2 deletions internal/service/editortoken/editortoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

0 comments on commit e8c2c60

Please sign in to comment.