Some checks failed
Docker Build / build-and-push-image (push) Failing after 18s
Adds an authentication layer in front of the bot handlers : - Auth handler on the principal bot (@arcodange_factory_bot, slug factory) parses /start, /auth <code>, /whoami, /logout. On a successful /auth, the message containing the code is best-effort deleted from the user's chat (replay defense). - Redis-backed sessions (key tg-gw:auth:<from.id>, TTL 24h, configurable via AUTH_SESSION_TTL). Constant-time secret compare via crypto/subtle. - ALLOWED_USERS env (CSV of Telegram user IDs) — silent-drops anyone not in the list before the auth gate runs. - New per-bot field 'requireAuth' (pointer-bool). Default = true (secure by default). Auto-forced to false for handler=auth (chicken-and-egg). - Server gates: allowlist first, then requireAuth before handler dispatch. - Fail-at-startup if a bot is configured with handler=auth or requireAuth: true while AUTH_SECRET is unset. Design: factory/docs/adr/20260509-telegram-gateway-auth.md (in factory PR). User docs: AUTH.md (new), HOWTO_ADD_BOT.md (Cas 2 updated for default true and gated flow). New deps: github.com/redis/go-redis/v9. Refs ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 1.5.
113 lines
3.2 KiB
Go
113 lines
3.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
type Server struct {
|
|
registry *Registry
|
|
auth *Auth
|
|
allowlist Allowlist
|
|
tg *TelegramClient
|
|
}
|
|
|
|
func NewServer(r *Registry, auth *Auth, allow Allowlist, tg *TelegramClient) *Server {
|
|
return &Server{registry: r, auth: auth, allowlist: allow, tg: tg}
|
|
}
|
|
|
|
func (s *Server) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", s.health)
|
|
mux.HandleFunc("/readyz", s.ready)
|
|
mux.HandleFunc("/bot/", s.botWebhook)
|
|
return chain(mux, recoverMW, accessLogMW)
|
|
}
|
|
|
|
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprint(w, "OK")
|
|
}
|
|
|
|
func (s *Server) ready(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprint(w, "OK")
|
|
}
|
|
|
|
func (s *Server) botWebhook(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
slug := strings.TrimPrefix(r.URL.Path, "/bot/")
|
|
slug = strings.Trim(slug, "/")
|
|
if slug == "" || strings.Contains(slug, "/") {
|
|
http.Error(w, "bot slug missing or malformed", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
bot, ok := s.registry.Get(slug)
|
|
if !ok {
|
|
http.Error(w, "unknown bot", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if !verifyTelegramSecret(r.Header.Get("X-Telegram-Bot-Api-Secret-Token"), bot.Secret) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var update Update
|
|
// NOTE: pas de DisallowUnknownFields — Telegram ajoute des champs
|
|
// (entities, sticker, photo, forum_topic…) au fil du temps. On reste
|
|
// tolérant et on n'extrait que ce dont on a besoin.
|
|
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
|
http.Error(w, "bad update payload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Allowlist gate (Phase 1.5). When ALLOWED_USERS is set and the user is
|
|
// not in it, ack 200 and silently drop. See ADR 20260509.
|
|
userID, _ := update.UserID()
|
|
if !s.allowlist.IsAllowed(userID) {
|
|
log.Printf("bot=%s update=%d dropped: user=%d not in ALLOWED_USERS", slug, update.UpdateID, userID)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprint(w, "{}")
|
|
return
|
|
}
|
|
|
|
// requireAuth gate (Phase 1.5). When the bot opts in, redirect
|
|
// unauthenticated users to /auth on the principal bot.
|
|
if bot.RequireAuth {
|
|
ok, err := s.auth.IsAuthed(r.Context(), userID)
|
|
if err != nil {
|
|
log.Printf("bot=%s update=%d auth check error: %v", slug, update.UpdateID, err)
|
|
// Fail-closed : on Redis errors, refuse access (consistent with the ADR).
|
|
ok = false
|
|
}
|
|
if !ok {
|
|
if chatID, hasChat := update.ChatID(); hasChat && s.tg != nil {
|
|
_ = s.tg.SendMessage(r.Context(), bot.Token, SendMessageParams{
|
|
ChatID: chatID,
|
|
Text: "🔒 Authentifie-toi d'abord avec `/auth <code>` chez @arcodange_factory_bot",
|
|
})
|
|
}
|
|
log.Printf("bot=%s update=%d gated: user=%d not authed", slug, update.UpdateID, userID)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprint(w, "{}")
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := bot.Handler.Handle(r.Context(), update, bot); err != nil {
|
|
log.Printf("bot=%s update=%d handler error: %v", slug, update.UpdateID, err)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprint(w, "{}")
|
|
}
|