Phase 1.5 — auth layer (Redis sessions, allowlist, requireAuth)
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.
This commit is contained in:
2026-05-09 13:56:30 +02:00
parent 6228169ac1
commit 07115e3162
15 changed files with 679 additions and 54 deletions

View File

@@ -9,11 +9,14 @@ import (
)
type Server struct {
registry *Registry
registry *Registry
auth *Auth
allowlist Allowlist
tg *TelegramClient
}
func NewServer(r *Registry) *Server {
return &Server{registry: r}
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 {
@@ -67,6 +70,39 @@ func (s *Server) botWebhook(w http.ResponseWriter, r *http.Request) {
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)
}