feat(11-03): add the lighthouse realtime package and its Centrifugo driver
- lighthouse: Service/From with realtime.driver selection, RegisterDriver registry, null/log/memory drivers, Route/Surface/Mount, users and actors - centrifugo: HTTP API client (apikey header, 2xx success, no request without a key), five-generator HS256 TokenIssuer, TokenHandler with the WinterCMS 401/503 bodies - module README and root modules table row
This commit is contained in:
145
modules/lighthouse/centrifugo/token.go
Normal file
145
modules/lighthouse/centrifugo/token.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package centrifugo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// anonymousTTL is the lifetime of Anonymous tokens.
|
||||
const anonymousTTL = 300 * time.Second
|
||||
|
||||
// TokenIssuer signs Centrifugo connection and subscription tokens with
|
||||
// HS256. Its claims match the WinterCMS JwtTokenGenerator. It is safe for
|
||||
// concurrent use.
|
||||
type TokenIssuer struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
// Now is the clock used for exp; nil means time.Now.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// NewTokenIssuer returns an issuer for secret with tokens valid for ttl
|
||||
// (DefaultTokenTTL when ttl is not positive).
|
||||
func NewTokenIssuer(secret string, ttl time.Duration) *TokenIssuer {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultTokenTTL
|
||||
}
|
||||
return &TokenIssuer{secret: []byte(secret), ttl: ttl}
|
||||
}
|
||||
|
||||
// Configured reports whether a signing secret is set.
|
||||
func (i *TokenIssuer) Configured() bool { return i != nil && len(i.secret) > 0 }
|
||||
|
||||
type userInfo struct {
|
||||
Name *string `json:"name"`
|
||||
}
|
||||
|
||||
type userClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Exp int64 `json:"exp"`
|
||||
Info userInfo `json:"info"`
|
||||
}
|
||||
|
||||
type channelClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Channel string `json:"channel"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type anonymousClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type identifierClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Exp int64 `json:"exp"`
|
||||
Info json.RawMessage `json:"info"`
|
||||
}
|
||||
|
||||
// ForUser returns a connection token with exactly the claims sub (the user
|
||||
// id as a string), exp (now + TTL) and info {"name": u.Name}. info carries
|
||||
// nothing else: no email, no other ids.
|
||||
func (i *TokenIssuer) ForUser(u lighthouse.User) (string, error) {
|
||||
return i.sign(userClaims{Sub: userSub(u.ID), Exp: i.exp(i.ttl), Info: userInfo{Name: u.Name}})
|
||||
}
|
||||
|
||||
// Subscription returns a subscription token with the claims sub, channel
|
||||
// and exp.
|
||||
func (i *TokenIssuer) Subscription(u lighthouse.User, channel string) (string, error) {
|
||||
return i.sign(channelClaims{Sub: userSub(u.ID), Channel: channel, Exp: i.exp(i.ttl)})
|
||||
}
|
||||
|
||||
// Anonymous returns a connection token with sub "" and exp now + 5 minutes.
|
||||
func (i *TokenIssuer) Anonymous() (string, error) {
|
||||
return i.sign(anonymousClaims{Sub: "", Exp: i.exp(anonymousTTL)})
|
||||
}
|
||||
|
||||
// ForIdentifier returns a connection token for a non-user identifier with
|
||||
// the claims sub, exp and info. An empty info is encoded as [], as the
|
||||
// WinterCMS generator's empty PHP array is.
|
||||
func (i *TokenIssuer) ForIdentifier(identifier string, info map[string]any) (string, error) {
|
||||
raw := json.RawMessage("[]")
|
||||
if len(info) > 0 {
|
||||
b, err := marshal(info)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw = b
|
||||
}
|
||||
return i.sign(identifierClaims{Sub: identifier, Exp: i.exp(i.ttl), Info: raw})
|
||||
}
|
||||
|
||||
// SubscriptionForIdentifier returns a subscription token for a non-user
|
||||
// identifier with the claims sub, channel and exp.
|
||||
func (i *TokenIssuer) SubscriptionForIdentifier(identifier, channel string) (string, error) {
|
||||
return i.sign(channelClaims{Sub: identifier, Channel: channel, Exp: i.exp(i.ttl)})
|
||||
}
|
||||
|
||||
func (i *TokenIssuer) exp(ttl time.Duration) int64 {
|
||||
now := time.Now
|
||||
if i != nil && i.Now != nil {
|
||||
now = i.Now
|
||||
}
|
||||
return now().Add(ttl).Unix()
|
||||
}
|
||||
|
||||
func (i *TokenIssuer) sign(claims any) (string, error) {
|
||||
if !i.Configured() {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
raw, err := marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, orderedClaims(raw)).SignedString(i.secret)
|
||||
}
|
||||
|
||||
func userSub(id uint) string { return strconv.FormatUint(uint64(id), 10) }
|
||||
|
||||
func marshal(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
|
||||
}
|
||||
|
||||
// orderedClaims keeps the claim order of the struct it was marshalled
|
||||
// from. The jwt.Claims methods are never used for signing.
|
||||
type orderedClaims json.RawMessage
|
||||
|
||||
func (c orderedClaims) MarshalJSON() ([]byte, error) { return c, nil }
|
||||
func (orderedClaims) GetExpirationTime() (*jwt.NumericDate, error) { return nil, nil }
|
||||
func (orderedClaims) GetIssuedAt() (*jwt.NumericDate, error) { return nil, nil }
|
||||
func (orderedClaims) GetNotBefore() (*jwt.NumericDate, error) { return nil, nil }
|
||||
func (orderedClaims) GetIssuer() (string, error) { return "", nil }
|
||||
func (orderedClaims) GetSubject() (string, error) { return "", nil }
|
||||
func (orderedClaims) GetAudience() (jwt.ClaimStrings, error) { return nil, nil }
|
||||
Reference in New Issue
Block a user