mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 16:17:25 +00:00
fix(pairing): fix browser approval stuck + security hardening
Squash-merge PR #225 with security fixes: - Fix browser pairing stuck on "Waiting for approval" (stale closure: useState → useRef for senderID in pairing-form) - Fix auto-kick after pairing (RequireAuth now accepts senderID, onAuthFailure skips logout for paired browser sessions) - Allow browser-paired users to access HTTP APIs via X-GoClaw-Sender-Id header with fail-closed IsPaired check - Remove ad-hoc IsInternalOrBrowser(), use channels.IsInternalChannel() - Log failed HTTP pairing auth attempts for security monitoring - Pass senderID to HttpClient for authenticated HTTP requests
This commit is contained in:
1 parent
89303f5624
commit
b0bd4d6198
7 files changed
+50
-9
No files matched your search
@@ -354,6 +354,11 @@ func runGateway() {
|
||||
httpapi.InitAPIKeyCache(pgStores.APIKeys, msgBus)
|
||||
}
|
||||
|
||||
// Allow browser-paired users to access HTTP APIs
|
||||
if pgStores.Pairing != nil {
|
||||
httpapi.InitPairingAuth(pgStores.Pairing)
|
||||
}
|
||||
|
||||
// Memory management API (wired directly, only needs MemoryStore + token)
|
||||
if pgStores != nil && pgStores.Memory != nil {
|
||||
server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token))
|
||||
|
||||
@@ -150,6 +150,11 @@ func wireChannelEventSubscribers(
|
||||
// Wire pairing approval notification → channel (matching TS notifyPairingApproved).
|
||||
botName := cfg.ResolveDisplayName("default")
|
||||
pairingMethods.SetOnApprove(func(ctx context.Context, channel, chatID, senderID string) {
|
||||
// Browser/internal channels use WebSocket — UI polls approval status directly.
|
||||
if channels.IsInternalChannel(channel) {
|
||||
slog.Debug("pairing approved for internal channel, skipping notification", "channel", channel)
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("✅ %s access approved. Send a message to start chatting.", botName)
|
||||
// Group pairings need group_id metadata so channels (e.g. Zalo) route to group API.
|
||||
if strings.HasPrefix(senderID, "group:") {
|
||||
|
||||
@@ -76,6 +76,7 @@ func extractAgentID(r *http.Request, model string) string {
|
||||
// --- Package-level API key cache for shared auth ---
|
||||
|
||||
var pkgAPIKeyCache *apiKeyCache
|
||||
var pkgPairingStore store.PairingStore
|
||||
|
||||
// InitAPIKeyCache initializes the shared API key cache with TTL and pubsub invalidation.
|
||||
// Must be called once during server startup before handling requests.
|
||||
@@ -90,6 +91,12 @@ func InitAPIKeyCache(s store.APIKeyStore, mb *bus.MessageBus) {
|
||||
}
|
||||
}
|
||||
|
||||
// InitPairingAuth sets the pairing store for HTTP auth.
|
||||
// Allows browser-paired users to access HTTP APIs via X-GoClaw-Sender-Id header.
|
||||
func InitPairingAuth(ps store.PairingStore) {
|
||||
pkgPairingStore = ps
|
||||
}
|
||||
|
||||
// ResolveAPIKey checks if the bearer token is a valid API key using the shared cache.
|
||||
// Returns the key data and derived role, or nil if not found/expired/revoked.
|
||||
func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permissions.Role) {
|
||||
@@ -123,6 +130,18 @@ func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult
|
||||
if _, role := ResolveAPIKey(r.Context(), bearer); role != "" {
|
||||
return authResult{Role: role, Authenticated: true}
|
||||
}
|
||||
// Browser pairing → operator (via X-GoClaw-Sender-Id header)
|
||||
if senderID := r.Header.Get("X-GoClaw-Sender-Id"); senderID != "" && pkgPairingStore != nil {
|
||||
paired, err := pkgPairingStore.IsPaired(senderID, "browser")
|
||||
if err == nil && paired {
|
||||
return authResult{Role: permissions.RoleOperator, Authenticated: true}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("security.http_pairing_check_failed", "sender_id", senderID, "error", err)
|
||||
} else {
|
||||
slog.Warn("security.http_pairing_auth_failed", "sender_id", senderID, "ip", r.RemoteAddr)
|
||||
}
|
||||
}
|
||||
// No auth configured → operator (backward compat)
|
||||
if gatewayToken == "" {
|
||||
return authResult{Role: permissions.RoleOperator, Authenticated: true}
|
||||
|
||||
@@ -7,6 +7,7 @@ export class HttpClient {
|
||||
private baseUrl: string,
|
||||
private getToken: () => string,
|
||||
private getUserId: () => string,
|
||||
private getSenderID: () => string = () => "",
|
||||
) {}
|
||||
|
||||
async get<T>(path: string, params?: Record<string, string>): Promise<T> {
|
||||
@@ -108,6 +109,8 @@ export class HttpClient {
|
||||
if (token) h["Authorization"] = `Bearer ${token}`;
|
||||
const userId = this.getUserId();
|
||||
if (userId) h["X-GoClaw-User-Id"] = userId;
|
||||
const senderID = this.getSenderID();
|
||||
if (senderID) h["X-GoClaw-Sender-Id"] = senderID;
|
||||
return h;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ export function WsProvider({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
);
|
||||
wsRef.current.onAuthFailure = () => {
|
||||
useAuthStore.getState().logout();
|
||||
// Don't logout if authenticated via browser pairing (no token)
|
||||
const state = useAuthStore.getState();
|
||||
if (state.senderID && !state.token) return;
|
||||
state.logout();
|
||||
};
|
||||
}
|
||||
const ws = wsRef.current;
|
||||
@@ -41,9 +44,13 @@ export function WsProvider({ children }: { children: React.ReactNode }) {
|
||||
"",
|
||||
() => useAuthStore.getState().token,
|
||||
() => useAuthStore.getState().userId,
|
||||
() => useAuthStore.getState().senderID,
|
||||
);
|
||||
client.onAuthFailure = () => {
|
||||
useAuthStore.getState().logout();
|
||||
// Don't logout if authenticated via browser pairing (no token)
|
||||
const state = useAuthStore.getState();
|
||||
if (state.senderID && !state.token) return;
|
||||
state.logout();
|
||||
};
|
||||
return client;
|
||||
}, []);
|
||||
|
||||
@@ -5,9 +5,10 @@ import { ROUTES } from "@/lib/constants";
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const userId = useAuthStore((s) => s.userId);
|
||||
const senderID = useAuthStore((s) => s.senderID);
|
||||
const location = useLocation();
|
||||
|
||||
if (!token || !userId) {
|
||||
if ((!token && !senderID) || !userId) {
|
||||
return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function PairingForm({ onApproved }: PairingFormProps) {
|
||||
const { t } = useTranslation("login");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [senderID, setSenderID] = useState<string | null>(null);
|
||||
const senderIDRef = useRef<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<PairingStatus>("idle");
|
||||
|
||||
@@ -87,14 +87,15 @@ export function PairingForm({ onApproved }: PairingFormProps) {
|
||||
payload.sender_id
|
||||
) {
|
||||
setCode(payload.pairing_code as string);
|
||||
setSenderID(payload.sender_id as string);
|
||||
const sid = payload.sender_id as string;
|
||||
senderIDRef.current = sid;
|
||||
setStatus("pending");
|
||||
startPolling(ws, payload.sender_id as string);
|
||||
startPolling(ws, sid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.status === "approved" && senderID) {
|
||||
handleApproved(senderID);
|
||||
if (payload.status === "approved" && senderIDRef.current) {
|
||||
handleApproved(senderIDRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,7 +134,7 @@ export function PairingForm({ onApproved }: PairingFormProps) {
|
||||
if (pollRef.current) clearTimeout(pollRef.current);
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
setCode(null);
|
||||
setSenderID(null);
|
||||
senderIDRef.current = null;
|
||||
setStatus("idle");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user