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.
136 lines
5.1 KiB
Go
136 lines
5.1 KiB
Go
// 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
|
|
}
|