mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-12 14:12:31 +00:00
0b5124a8f1
- Change WS pairing check from fail-open to fail-closed on DB error (router.go: previously granted RoleOperator on any IsPaired() error) - Add "browser" to InternalChannels so it's properly excluded from outbound dispatch without ad-hoc helpers - Rate-limit browser.pairing.status endpoint to prevent sender_id enumeration (reuses server RateLimiter via PairingMethods injection) - Add expires_at column to paired_devices with 30-day TTL for defense-in-depth; IsPaired() now checks expiry, ListPaired() prunes - Add confidence_score column to team_tasks, team_messages, team_task_comments - Bump RequiredSchemaVersion to 21
251 lines
8.2 KiB
Go
251 lines
8.2 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// PairingApproveCallback is called after a pairing is approved.
|
|
// channel is the channel name (e.g., "telegram"), chatID is the chat to notify,
|
|
// senderID identifies the paired entity (e.g., "group:XXXX" for group pairings).
|
|
type PairingApproveCallback func(ctx context.Context, channel, chatID, senderID string)
|
|
|
|
// PairingMethods handles device.pair.request, device.pair.approve, device.pair.list, device.pair.revoke.
|
|
type PairingMethods struct {
|
|
service store.PairingStore
|
|
msgBus *bus.MessageBus
|
|
onApprove PairingApproveCallback
|
|
broadcaster func(protocol.EventFrame)
|
|
rateLimiter *gateway.RateLimiter
|
|
}
|
|
|
|
func NewPairingMethods(service store.PairingStore, msgBus *bus.MessageBus, rateLimiter *gateway.RateLimiter) *PairingMethods {
|
|
return &PairingMethods{service: service, msgBus: msgBus, rateLimiter: rateLimiter}
|
|
}
|
|
|
|
// SetOnApprove sets a callback that fires after a pairing is approved.
|
|
func (m *PairingMethods) SetOnApprove(cb PairingApproveCallback) {
|
|
m.onApprove = cb
|
|
}
|
|
|
|
// SetBroadcaster sets a function to broadcast events to all WS clients.
|
|
func (m *PairingMethods) SetBroadcaster(fn func(protocol.EventFrame)) {
|
|
m.broadcaster = fn
|
|
}
|
|
|
|
func (m *PairingMethods) Register(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodPairingRequest, m.handleRequest)
|
|
router.Register(protocol.MethodPairingApprove, m.handleApprove)
|
|
router.Register(protocol.MethodPairingDeny, m.handleDeny)
|
|
router.Register(protocol.MethodPairingList, m.handleList)
|
|
router.Register(protocol.MethodPairingRevoke, m.handleRevoke)
|
|
router.Register(protocol.MethodBrowserPairingStatus, m.handleBrowserPairingStatus)
|
|
}
|
|
|
|
func (m *PairingMethods) handleRequest(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
SenderID string `json:"senderId"`
|
|
Channel string `json:"channel"`
|
|
ChatID string `json:"chatId"`
|
|
AccountID string `json:"accountId"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
if params.SenderID == "" || params.Channel == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired)))
|
|
return
|
|
}
|
|
|
|
if params.AccountID == "" {
|
|
params.AccountID = "default"
|
|
}
|
|
|
|
code, err := m.service.RequestPairing(params.SenderID, params.Channel, params.ChatID, params.AccountID, nil)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"code": code,
|
|
}))
|
|
}
|
|
|
|
func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Code string `json:"code"`
|
|
ApprovedBy string `json:"approvedBy"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
if params.Code == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCodeRequired)))
|
|
return
|
|
}
|
|
if params.ApprovedBy == "" {
|
|
params.ApprovedBy = "operator"
|
|
}
|
|
|
|
paired, err := m.service.ApprovePairing(params.Code, params.ApprovedBy)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
|
|
return
|
|
}
|
|
|
|
// Notify the user via channel (matching TS notifyPairingApproved).
|
|
// Use Background context: the CLI client may disconnect before the notification is sent.
|
|
if m.onApprove != nil && paired != nil {
|
|
go m.onApprove(context.Background(), paired.Channel, paired.ChatID, paired.SenderID)
|
|
}
|
|
|
|
if m.broadcaster != nil {
|
|
m.broadcaster(*protocol.NewEvent(protocol.EventDevicePairRes, map[string]any{"action": "approved"}))
|
|
}
|
|
|
|
emitAudit(m.msgBus, client, "pairing.approved", "pairing", params.Code)
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"paired": paired,
|
|
}))
|
|
}
|
|
|
|
func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
if params.Code == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCodeRequired)))
|
|
return
|
|
}
|
|
|
|
if err := m.service.DenyPairing(params.Code); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
|
|
return
|
|
}
|
|
|
|
if m.broadcaster != nil {
|
|
m.broadcaster(*protocol.NewEvent(protocol.EventDevicePairRes, map[string]any{"action": "denied"}))
|
|
}
|
|
|
|
emitAudit(m.msgBus, client, "pairing.denied", "pairing", params.Code)
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"denied": true,
|
|
}))
|
|
}
|
|
|
|
func (m *PairingMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
pending := m.service.ListPending()
|
|
paired := m.service.ListPaired()
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"pending": pending,
|
|
"paired": paired,
|
|
}))
|
|
}
|
|
|
|
func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
SenderID string `json:"senderId"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
if params.SenderID == "" || params.Channel == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired)))
|
|
return
|
|
}
|
|
|
|
if err := m.service.RevokePairing(params.SenderID, params.Channel); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
|
|
return
|
|
}
|
|
|
|
if m.broadcaster != nil {
|
|
m.broadcaster(*protocol.NewEvent(protocol.EventDevicePairRes, map[string]any{"action": "revoked"}))
|
|
}
|
|
|
|
// Broadcast revocation so the server can force-disconnect the active session.
|
|
if m.msgBus != nil {
|
|
m.msgBus.Broadcast(bus.Event{
|
|
Name: bus.EventPairingRevoked,
|
|
Payload: bus.PairingRevokedPayload{
|
|
SenderID: params.SenderID,
|
|
Channel: params.Channel,
|
|
},
|
|
})
|
|
}
|
|
|
|
emitAudit(m.msgBus, client, "pairing.revoked", "pairing", params.SenderID)
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"revoked": true,
|
|
}))
|
|
}
|
|
|
|
// handleBrowserPairingStatus lets a pending browser client check if its pairing code has been approved.
|
|
// Called by unauthenticated clients during the browser pairing flow.
|
|
func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
// Rate-limit unauthenticated polling to prevent sender_id enumeration.
|
|
if m.rateLimiter != nil && m.rateLimiter.Enabled() && !m.rateLimiter.Allow("pairing:"+client.RemoteAddr()) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrResourceExhausted, "rate limited"))
|
|
return
|
|
}
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
SenderID string `json:"sender_id"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
|
|
if params.SenderID == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderIDRequired)))
|
|
return
|
|
}
|
|
|
|
paired, pairErr := m.service.IsPaired(params.SenderID, "browser")
|
|
if pairErr != nil {
|
|
slog.Warn("security.pairing_check_failed", "sender_id", params.SenderID, "error", pairErr)
|
|
}
|
|
if paired {
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"status": "approved",
|
|
}))
|
|
return
|
|
}
|
|
|
|
// Check if the pairing request still exists (not expired)
|
|
pending := m.service.ListPending()
|
|
for _, p := range pending {
|
|
if p.SenderID == params.SenderID && p.Channel == "browser" {
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"status": "pending",
|
|
}))
|
|
return
|
|
}
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"status": "expired",
|
|
}))
|
|
}
|