mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-09 00:30:02 +00:00
175 lines
4.7 KiB
Go
175 lines
4.7 KiB
Go
// Package editortoken utilizes JWT to create / validate a token for
|
|
// the frontend
|
|
package editortoken
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/Luzifer/twitch-bot/v3/pkg/database"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
const (
|
|
coreMetaSigningKey = "editortoken:signing-key"
|
|
tokenValidity = time.Hour
|
|
)
|
|
|
|
type (
|
|
claims struct {
|
|
Modules []string `json:"modules"`
|
|
TwitchUser *twitchUser `json:"twitchUser,omitempty"`
|
|
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
twitchUser struct {
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// Service manages the permission database
|
|
Service struct{ db database.Connector }
|
|
)
|
|
|
|
// New creates a new Service on the given database
|
|
func New(db database.Connector) *Service {
|
|
return &Service{db}
|
|
}
|
|
|
|
// CreateUserToken packs user-id and user name into a JWT, signs it
|
|
// and returns the signed token
|
|
func (s Service) CreateUserToken(id, user string, modules []string) (token string, expiresAt time.Time, err error) {
|
|
cl := claims{
|
|
Modules: modules,
|
|
TwitchUser: &twitchUser{
|
|
ID: id,
|
|
Name: user,
|
|
},
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: "Twitch-Bot",
|
|
Subject: id,
|
|
Audience: []string{},
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenValidity)),
|
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
|
|
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, modules []string, err error) {
|
|
var cl claims
|
|
|
|
tok, err := jwt.ParseWithClaims(token, &cl, func(*jwt.Token) (any, error) {
|
|
priv, err := s.getSigningKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting private key: %w", err)
|
|
}
|
|
|
|
return priv.Public(), nil
|
|
})
|
|
if err != nil {
|
|
// Something went wrong when parsing & validating
|
|
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, expiresAt, claims.Modules, nil
|
|
}
|
|
|
|
// We had no error but were not able to convert the claims
|
|
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) {
|
|
err = s.db.ReadEncryptedCoreMeta(coreMetaSigningKey, &priv)
|
|
switch {
|
|
case err == nil:
|
|
// We read the previously generated key
|
|
return priv, nil
|
|
|
|
case errors.Is(err, database.ErrCoreMetaNotFound):
|
|
// We don't have a key yet or the key was wiped for some reason,
|
|
// we generate a new one which automatically is stored for later
|
|
// retrieval.
|
|
if priv, err = s.generateSigningKey(); err != nil {
|
|
return nil, fmt.Errorf("creating signing key: %w", err)
|
|
}
|
|
|
|
return priv, nil
|
|
|
|
default:
|
|
// Something went wrong, bail.
|
|
return nil, fmt.Errorf("reading signing key: %w", err)
|
|
}
|
|
}
|
|
|
|
func (s Service) generateSigningKey() (ed25519.PrivateKey, error) {
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating key: %w", err)
|
|
}
|
|
|
|
if err = s.db.StoreEncryptedCoreMeta(coreMetaSigningKey, priv); err != nil {
|
|
return nil, fmt.Errorf("storing signing key: %w", err)
|
|
}
|
|
|
|
return priv, nil
|
|
}
|