- 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
146 lines
4.5 KiB
Go
146 lines
4.5 KiB
Go
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 }
|