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

135
handler_auth.go Normal file
View File

@@ -0,0 +1,135 @@
// Voir factory/docs/adr/20260509-telegram-gateway-auth.md
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
)
// AuthHandler is the handler for the principal bot (factory). It accepts
// the auth-related commands (/start, /auth <code>, /whoami, /logout) and
// falls back to a help message for anything else. On a successful /auth,
// the original chat message is best-effort deleted so the secret doesn't
// linger in the user's chat history.
type AuthHandler struct {
tg *TelegramClient
auth *Auth
}
const helpMessage = "Commandes disponibles :\n" +
" /auth <code> — ouvrir une session (24 h)\n" +
" /whoami — afficher l'état de session\n" +
" /logout — fermer la session\n"
const startMessage = "Bonjour 👋\n\n" +
"Je suis le bot d'authentification du gateway Arcodange.\n" +
"Envoie `/auth <code>` pour ouvrir une session, puis utilise les autres bots du gateway normalement.\n\n" + helpMessage
func (h *AuthHandler) Handle(ctx context.Context, update Update, bot Bot) error {
chatID, ok := update.ChatID()
if !ok {
return nil
}
userID, _ := update.UserID()
text := strings.TrimSpace(update.Text())
switch {
case text == "/start" || text == "/start@"+bot.Slug:
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: startMessage})
case strings.HasPrefix(text, "/auth"):
provided := strings.TrimSpace(strings.TrimPrefix(text, "/auth"))
// strip a possible "@<botname>" suffix on the command (e.g. /auth@arcodange_factory_bot s3cr3t)
if at := strings.IndexAny(provided, " "); at > 0 && strings.HasPrefix(provided, "@") {
provided = strings.TrimSpace(provided[at:])
}
return h.handleAuthCommand(ctx, update, bot, chatID, userID, provided)
case text == "/whoami" || strings.HasPrefix(text, "/whoami@"):
return h.handleWhoami(ctx, bot, chatID, userID)
case text == "/logout" || strings.HasPrefix(text, "/logout@"):
return h.handleLogout(ctx, bot, chatID, userID)
default:
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: helpMessage})
}
}
func (h *AuthHandler) handleAuthCommand(ctx context.Context, update Update, bot Bot, chatID, userID int64, provided string) error {
if provided == "" {
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Usage : `/auth <code>`"})
}
if userID == 0 {
log.Printf("auth attempt user=<unknown> result=skip (no from.id)")
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Impossible d'identifier l'utilisateur."})
}
ok, err := h.auth.Login(ctx, userID, provided)
if err != nil {
log.Printf("auth attempt user=%d result=error err=%v", userID, err)
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Erreur interne, réessaie plus tard."})
}
if !ok {
log.Printf("auth attempt user=%d result=fail", userID)
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "❌ Mauvais code."})
}
log.Printf("auth attempt user=%d result=ok", userID)
// Replay defense — best-effort, ignore errors (insufficient permissions etc.)
if msgID := messageID(update); msgID != 0 {
if delErr := h.tg.DeleteMessage(ctx, bot.Token, chatID, msgID); delErr != nil {
log.Printf("deleteMessage best-effort failed user=%d: %v", userID, delErr)
}
}
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{
ChatID: chatID,
Text: "✅ Authentifié pour 24 h. Tu peux maintenant utiliser les autres bots du gateway.",
})
}
func (h *AuthHandler) handleWhoami(ctx context.Context, bot Bot, chatID, userID int64) error {
if userID == 0 {
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Impossible d'identifier l'utilisateur."})
}
authed, _ := h.auth.IsAuthed(ctx, userID)
if !authed {
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{
ChatID: chatID,
Text: fmt.Sprintf("user=%d : non authentifié.\nUtilise `/auth <code>` pour ouvrir une session.", userID),
})
}
ttl, _ := h.auth.Remaining(ctx, userID)
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{
ChatID: chatID,
Text: fmt.Sprintf("user=%d : ✅ authentifié, reste %s.", userID, ttl.Round(time.Second)),
})
}
func (h *AuthHandler) handleLogout(ctx context.Context, bot Bot, chatID, userID int64) error {
if userID == 0 {
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Impossible d'identifier l'utilisateur."})
}
if err := h.auth.Logout(ctx, userID); err != nil {
log.Printf("logout user=%d err=%v", userID, err)
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Erreur interne, réessaie plus tard."})
}
log.Printf("logout user=%d", userID)
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Déconnecté."})
}
// messageID extracts the original Telegram message ID from an Update so we
// can delete it on a successful /auth (replay defense).
func messageID(u Update) int64 {
switch {
case u.Message != nil:
return u.Message.MessageID
case u.EditedMessage != nil:
return u.EditedMessage.MessageID
}
return 0
}