material_manage_system/internal/service/websocket_ticket.go

140 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"sync"
"time"
)
// WebSocket 票据默认有效期:短期、一次性,把 /ws 握手从长期 JWT 中解耦。
// 浏览器不能把 JWT 放 URL,也不能仅凭 URL 里的公司 ID 订阅,所以先用受保护的接口
// 换取一张短暂票据,再凭它建连。
const WebSocketTicketTTL = 30 * time.Second
var (
// ErrWebSocketTicketInvalid 表示票据不存在、格式非法或已被消费(重放)。
ErrWebSocketTicketInvalid = errors.New("websocket ticket invalid or already used")
// ErrWebSocketTicketExpired 表示票据已超过有效期。
ErrWebSocketTicketExpired = errors.New("websocket ticket expired")
)
// WebSocketTicket 绑定一次握手所需的身份与公司:票据 ID 本身即一次性随机标识,
// 连同会话指纹和令牌到期时间记录;不保存原始 JWT。
type WebSocketTicket struct {
Ticket string
UserID int64
OrganizationID int64
IssuedAt time.Time
ExpiresAt time.Time
SessionID string
SessionExpiresAt int64
}
// WebSocketTicketService 维护未消费的票据。票据只驻留内存,进程重启即全部失效。
type WebSocketTicketService struct {
ttl time.Duration
now func() time.Time
mu sync.Mutex
tickets map[string]*WebSocketTicket
}
// NewWebSocketTicketService 构造票据服务。ttl 可选,测试用它构造“已过期”票据,
// 不传则使用默认 30 秒。
func NewWebSocketTicketService(ttl ...time.Duration) *WebSocketTicketService {
effective := WebSocketTicketTTL
if len(ttl) > 0 {
effective = ttl[0]
}
return &WebSocketTicketService{
ttl: effective,
now: time.Now,
tickets: make(map[string]*WebSocketTicket),
}
}
// Issue 为指定用户与公司签发一张一次性票据。
func (s *WebSocketTicketService) Issue(userID, organizationID int64, sessionToken ...string) (*WebSocketTicket, error) {
if userID <= 0 || organizationID <= 0 {
return nil, ErrInvalidOrganizationID
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
now := s.now()
ticket := &WebSocketTicket{
Ticket: hex.EncodeToString(raw),
UserID: userID,
OrganizationID: organizationID,
IssuedAt: now,
ExpiresAt: now.Add(s.ttl),
}
if len(sessionToken) > 0 {
principal, err := NewAuthService().ValidateToken(sessionToken[0])
if err != nil {
return nil, err
}
if principal.UserID != userID {
return nil, ErrWebSocketTicketInvalid
}
ticket.SessionID = TokenSessionID(sessionToken[0])
ticket.SessionExpiresAt = principal.Exp
}
s.mu.Lock()
defer s.mu.Unlock()
// 顺手清理已过期票据,避免长期运行后 map 无限增长。
s.purgeExpiredLocked(now)
s.tickets[ticket.Ticket] = ticket
return ticket, nil
}
// Consume 校验并一次性消费票据。无论是否过期都先删除:过期票据、已消费票据
// 和重放请求都无法再次连接。返回的票据已通过有效期检查。
func (s *WebSocketTicketService) Consume(value string) (*WebSocketTicket, error) {
if value == "" {
return nil, ErrWebSocketTicketInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
ticket, ok := s.tickets[value]
if !ok {
return nil, ErrWebSocketTicketInvalid
}
delete(s.tickets, value)
if s.now().After(ticket.ExpiresAt) {
return nil, ErrWebSocketTicketExpired
}
if ticket.SessionID != "" {
if err := ValidateSession(ticket.SessionID, ticket.SessionExpiresAt); err != nil {
return nil, err
}
}
return ticket, nil
}
func (s *WebSocketTicketService) purgeExpiredLocked(now time.Time) {
for key, ticket := range s.tickets {
if now.After(ticket.ExpiresAt) {
delete(s.tickets, key)
}
}
}
// defaultTicketService 是进程内共享的票据服务,签发接口与握手共用同一实例。
var defaultTicketService = NewWebSocketTicketService()
// GetWebSocketTicketService 返回共享票据服务。
func GetWebSocketTicketService() *WebSocketTicketService {
return defaultTicketService
}
// SetDefaultWebSocketTicketService 仅供测试替换共享票据服务,返回恢复函数。
// 生产代码请使用 GetWebSocketTicketService。
func SetDefaultWebSocketTicketService(s *WebSocketTicketService) func() {
previous := defaultTicketService
defaultTicketService = s
return func() { defaultTicketService = previous }
}