Phase 1.5 — auth layer (Redis sessions, allowlist, requireAuth)
Some checks failed
Docker Build / build-and-push-image (push) Failing after 18s
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:
135
handler_auth.go
Normal file
135
handler_auth.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user