diff --git a/AUTH.md b/AUTH.md new file mode 100644 index 0000000..7dacfe8 --- /dev/null +++ b/AUTH.md @@ -0,0 +1,106 @@ +[← README](README.md) · [HOWTO_ADD_BOT](HOWTO_ADD_BOT.md) · **Authentification** + +> **Détails de design** : [factory ADR 20260509 — telegram-gateway auth](https://gitea.arcodange.lab/arcodange-org/factory/src/branch/main/docs/adr/20260509-telegram-gateway-auth.md) + +# Authentification + +Le gateway est protégé en deux couches : + +1. **Allowlist** (`ALLOWED_USERS`, optionnelle) — filtre la liste des Telegram user IDs autorisés à parler à n'importe quel bot. Hors-liste : silent drop, le bot ne répond rien (l'inconnu ne sait même pas que le bot existe). +2. **Session `/auth`** — par défaut, **tous les bots du gateway exigent une session active** (`requireAuth: true` par défaut). Pour ouvrir une session, il faut DM `@arcodange_factory_bot` avec `/auth `. Une session dure **24 h** (configurable via `AUTH_SESSION_TTL`). + +## Côté utilisateur + +### 1. Ouvrir une session + +1. Sur Telegram, ouvre `@arcodange_factory_bot`. +2. Envoie `/auth ` (le code t'a été partagé par l'admin du gateway). +3. Le bot répond `✅ Authentifié pour 24 h`. Ton message contenant le code est **automatiquement supprimé** du chat (replay defense). +4. Tu peux maintenant DM les autres bots normalement. + +### 2. Vérifier sa session + +``` +/whoami +``` + +Réponse type : `user=123456 : ✅ authentifié, reste 23h59m.` ou `user=123456 : non authentifié.`. + +### 3. Fermer une session + +``` +/logout +``` + +### 4. Quand un bot répond `🔒 Authentifie-toi …` + +Ta session a expiré (TTL dépassé) ou tu n'as jamais fait `/auth`. Va sur `@arcodange_factory_bot`, refais `/auth `. + +## Côté opérateur + +### Définir / changer le code `AUTH_SECRET` + +```bash +# Choisir un code fort (ex : openssl rand -hex 16) +SECRET='' + +kubectl -n telegram-gateway patch secret telegram-gateway-bots --type=json -p="[ + {\"op\":\"replace\",\"path\":\"/data/AUTH_SECRET\",\"value\":\"$(echo -n "$SECRET" | base64)\"} +]" + +# Restart pour reload du Secret +kubectl -n telegram-gateway rollout restart deploy/telegram-gateway +``` + +> Rotation : changer `AUTH_SECRET` invalide les **futurs** `/auth` mais **pas** les sessions Redis déjà ouvertes (elles vivent jusqu'à leur TTL). Pour invalider tout : `kubectl exec -n tools redis-0 -- redis-cli --scan --pattern 'tg-gw:auth:*' | xargs -I{} kubectl exec -n tools redis-0 -- redis-cli DEL {}`. + +### Définir / changer l'allowlist + +```bash +kubectl -n telegram-gateway patch secret telegram-gateway-bots --type=json -p="[ + {\"op\":\"replace\",\"path\":\"/data/ALLOWED_USERS\",\"value\":\"$(echo -n '12345,67890' | base64)\"} +]" +kubectl -n telegram-gateway rollout restart deploy/telegram-gateway +``` + +Pour **désactiver** l'allowlist (ouvrir à tout Telegram user en allowlist) : + +```bash +kubectl -n telegram-gateway patch secret telegram-gateway-bots --type=json -p='[ + {"op":"remove","path":"/data/ALLOWED_USERS"} +]' +kubectl -n telegram-gateway rollout restart deploy/telegram-gateway +``` + +### Inspecter une session Redis + +```bash +USER_ID=123456 +kubectl exec -n tools redis-0 -- redis-cli GET "tg-gw:auth:$USER_ID" # → "1" ou (nil) +kubectl exec -n tools redis-0 -- redis-cli TTL "tg-gw:auth:$USER_ID" # → secondes restantes +kubectl exec -n tools redis-0 -- redis-cli DEL "tg-gw:auth:$USER_ID" # → forcer logout +``` + +### Rendre un bot public (opt-out de l'auth) + +Dans `chart/values.yaml`, ajouter `requireAuth: false` au bot concerné : + +```yaml +bots: + statusbot: + handler: echo + requireAuth: false # bot public, pas de session requise +``` + +> Cas spécial : `handler: auth` (le bot principal `factory`) force toujours `requireAuth: false` automatiquement (sinon l'auth elle-même serait inaccessible — chicken-and-egg). + +## Limites connues + +- **Pas de TOTP / OTP rotatif** — un code partagé suffit pour cet usage privé. À reconsidérer si on ouvre à plus d'utilisateurs. +- **Pas de rate-limit sur `/auth`** — l'`ALLOWED_USERS` joue le rôle de garde-fou. Avec un code fort (≥ 128 bits), le bruteforce est inutile dans la fenêtre de TTL. +- **Sessions invalidées si Redis tombe** — fail-closed : tous les bots gated répondent `🔒` jusqu'à ce que Redis revienne. Acceptable. +- **`from.id`-based, pas IP-based** — Telegram n'expose pas l'IP du user au bot. Une session couvre tous les devices d'un même compte Telegram, ce qui est le comportement attendu. + +## Chemin de migration vers Vault (Phase 2+) + +Aujourd'hui, `AUTH_SECRET` et `ALLOWED_USERS` vivent dans un `Secret` k8s créé via `kubectl create secret`. À terme, ils basculeront dans Vault (toggle `vault.enabled: true` dans `chart/values.yaml`, provisionner `kvv2/telegram-gateway/config`). Le code n'a pas à changer — `envFrom: secretRef` consomme indifféremment un Secret manuel ou un Secret produit par VaultStaticSecret. diff --git a/HOWTO_ADD_BOT.md b/HOWTO_ADD_BOT.md index fcc16de..5f913b1 100644 --- a/HOWTO_ADD_BOT.md +++ b/HOWTO_ADD_BOT.md @@ -31,49 +31,74 @@ la session n'a rien à recevoir en retour. --- -## Cas 2 — Bot echo simple via le gateway (Phase 1) +## Cas 2 — Bot echo simple via le gateway (Phase 1, gated par auth) Utile pour valider la chaîne, créer un canal de log conversationnel, etc. +> **Auth (Phase 1.5, ADR [20260509](https://gitea.arcodange.lab/arcodange-org/factory/src/branch/main/docs/adr/20260509-telegram-gateway-auth.md))** : par défaut, **`requireAuth: true`** s'applique → tout user qui DM ce bot doit d'abord ouvrir une session via `/auth ` chez `@arcodange_factory_bot`. Voir [`AUTH.md`](AUTH.md). Pour rendre un bot public, ajouter explicitement `requireAuth: false`. + Steps (humain ou session Claude avec accès au cluster + au repo) : -```bash -# 1. @BotFather crée le bot, noter le TOKEN -TOKEN='1234:AAA...' -SLUG='monbot' # kebab-case, [a-z0-9-]+ -ENV_SLUG=$(echo "$SLUG" | tr 'a-z-' 'A-Z_') # ex: monbot → MONBOT -SECRET=$(openssl rand -hex 32) +1. **@BotFather crée le bot**, noter le TOKEN. -# 2. Patcher le Secret cluster (ajoute les 2 clés sans toucher aux existantes) -kubectl -n telegram-gateway patch secret telegram-gateway-bots --type=json -p="[ - {\"op\":\"add\",\"path\":\"/data/BOT_${ENV_SLUG}_TOKEN\",\"value\":\"$(echo -n "$TOKEN" | base64)\"}, - {\"op\":\"add\",\"path\":\"/data/BOT_${ENV_SLUG}_SECRET\",\"value\":\"$(echo -n "$SECRET" | base64)\"} -]" + ```bash + TOKEN='1234:AAA...' + SLUG='monbot' # kebab-case, [a-z0-9-]+ + ENV_SLUG=$(echo "$SLUG" | tr 'a-z-' 'A-Z_') # ex: monbot → MONBOT + SECRET=$(openssl rand -hex 32) + ``` -# 3. Déclarer le bot dans chart/values.yaml sous bots: -# (édite le fichier puis push) -cd /Users/gabrielradureau/Work/Vibe/telegram-gateway -# ajouter sous "bots:" : -# monbot: -# handler: echo -git add chart/values.yaml -git commit -m "bots: add $SLUG" -git push +2. **Patcher le Secret cluster** (ajoute les 2 clés sans toucher aux existantes) : -# 4. Forcer le rollout pour que le pod relise la ConfigMap (le checksum -# annotation s'en charge en théorie, mais on s'assure) -kubectl -n telegram-gateway rollout restart deploy/telegram-gateway -kubectl -n telegram-gateway rollout status deploy/telegram-gateway + ```bash + kubectl -n telegram-gateway patch secret telegram-gateway-bots --type=json -p="[ + {\"op\":\"add\",\"path\":\"/data/BOT_${ENV_SLUG}_TOKEN\",\"value\":\"$(echo -n "$TOKEN" | base64)\"}, + {\"op\":\"add\",\"path\":\"/data/BOT_${ENV_SLUG}_SECRET\",\"value\":\"$(echo -n "$SECRET" | base64)\"} + ]" + ``` -# 5. Enregistrer le webhook côté Telegram -export BOT_${ENV_SLUG}_TOKEN="$TOKEN" -export BOT_${ENV_SLUG}_SECRET="$SECRET" -make setwebhook SLUG="$SLUG" BASE_URL=https://tg.arcodange.fr +3. **Déclarer le bot** dans `chart/values.yaml` sous `bots:` : -# 6. Test : envoie un message au bot, attends < 2s pour l'echo -``` + ```yaml + bots: + monbot: + handler: echo + # requireAuth: true (implicite — défaut sécurisé) + ``` -**Limite Phase 1** : tous les bots ont le handler `echo`. Pas encore de routage vers une logique métier différente par bot. + Pour un bot public (notifications status, etc.), opt-out explicite : + + ```yaml + bots: + statusbot: + handler: echo + requireAuth: false + ``` + +4. **Push + rollout** : + + ```bash + cd /Users/gabrielradureau/Work/Vibe/telegram-gateway + git add chart/values.yaml + git commit -m "bots: add $SLUG" + git push + kubectl -n telegram-gateway rollout restart deploy/telegram-gateway + kubectl -n telegram-gateway rollout status deploy/telegram-gateway + ``` + +5. **Enregistrer le webhook côté Telegram** : + + ```bash + export BOT_${ENV_SLUG}_TOKEN="$TOKEN" + export BOT_${ENV_SLUG}_SECRET="$SECRET" + make setwebhook SLUG="$SLUG" BASE_URL=https://tg.arcodange.fr + ``` + +6. **Test** : + - Si `requireAuth` est laissé à true : envoie un message → réponse `🔒 /auth chez @arcodange_factory_bot` ; fais `/auth ` chez factory ; renvoie un message → echo en < 2 s. + - Si `requireAuth: false` : echo direct en < 2 s. + +**Limite Phase 1** : tous les bots ont le handler `echo` ou `auth`. Pas encore de routage vers une logique métier différente par bot — voir Cas 3. --- diff --git a/allowlist.go b/allowlist.go new file mode 100644 index 0000000..f053630 --- /dev/null +++ b/allowlist.go @@ -0,0 +1,55 @@ +// Voir factory/docs/adr/20260509-telegram-gateway-auth.md +package main + +import ( + "strconv" + "strings" +) + +// Allowlist filters incoming Telegram updates by `from.id`. Empty raw value +// means "open to all" (back-compat with Phase 1). When non-empty, only IDs +// in the set are allowed through ; everything else is silently dropped +// upstream of the auth gate (so unknown users don't even know the bot exists). +type Allowlist struct { + ids map[int64]struct{} + open bool +} + +// NewAllowlist parses a comma-separated list of int64 user IDs. Whitespace +// around items and empty items are ignored. Invalid items are skipped (with +// the caller responsible for logging if it cares). +func NewAllowlist(raw string) Allowlist { + raw = strings.TrimSpace(raw) + if raw == "" { + return Allowlist{open: true} + } + ids := make(map[int64]struct{}) + for _, s := range strings.Split(raw, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + id, err := strconv.ParseInt(s, 10, 64) + if err != nil { + continue + } + ids[id] = struct{}{} + } + if len(ids) == 0 { + // CSV provided but every entry was unparseable → treat as closed for safety. + return Allowlist{ids: ids, open: false} + } + return Allowlist{ids: ids, open: false} +} + +func (a Allowlist) IsAllowed(userID int64) bool { + if a.open { + return true + } + _, ok := a.ids[userID] + return ok +} + +func (a Allowlist) Open() bool { return a.open } + +func (a Allowlist) Size() int { return len(a.ids) } diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..536c212 --- /dev/null +++ b/auth.go @@ -0,0 +1,92 @@ +// Voir factory/docs/adr/20260509-telegram-gateway-auth.md +package main + +import ( + "context" + "crypto/subtle" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// Auth wraps a Redis client and exposes the session primitives the gateway +// needs : IsAuthed / Login / Logout / Remaining. The session key has the form +// `tg-gw:auth:` and a configurable TTL (24h by default). +type Auth struct { + rdb *redis.Client + prefix string + ttl time.Duration + secret string +} + +func NewAuth(redisURL, secret string, ttl time.Duration) (*Auth, error) { + if secret == "" { + return nil, fmt.Errorf("AUTH_SECRET is required for the auth layer") + } + if redisURL == "" { + return nil, fmt.Errorf("REDIS_URL is required for the auth layer") + } + opts, err := redis.ParseURL(redisURL) + if err != nil { + return nil, fmt.Errorf("parse REDIS_URL: %w", err) + } + rdb := redis.NewClient(opts) + + pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := rdb.Ping(pingCtx).Err(); err != nil { + return nil, fmt.Errorf("redis ping: %w", err) + } + + return &Auth{rdb: rdb, prefix: "tg-gw:auth:", ttl: ttl, secret: secret}, nil +} + +func (a *Auth) key(userID int64) string { + return fmt.Sprintf("%s%d", a.prefix, userID) +} + +func (a *Auth) IsAuthed(ctx context.Context, userID int64) (bool, error) { + if a == nil || userID == 0 { + return false, nil + } + n, err := a.rdb.Exists(ctx, a.key(userID)).Result() + if err != nil { + return false, err + } + return n > 0, nil +} + +// Login compares the provided code in constant time and, on match, sets the +// session key with the configured TTL. Returns ok=false on a wrong code (no +// error). The bool/error split lets the caller distinguish "wrong code" +// (user-facing) from "redis is down" (operator-facing). +func (a *Auth) Login(ctx context.Context, userID int64, providedSecret string) (bool, error) { + if a == nil { + return false, fmt.Errorf("auth not initialized") + } + if userID == 0 { + return false, fmt.Errorf("missing user id") + } + if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(a.secret)) != 1 { + return false, nil + } + if err := a.rdb.Set(ctx, a.key(userID), "1", a.ttl).Err(); err != nil { + return false, fmt.Errorf("redis SET: %w", err) + } + return true, nil +} + +func (a *Auth) Logout(ctx context.Context, userID int64) error { + if a == nil || userID == 0 { + return nil + } + return a.rdb.Del(ctx, a.key(userID)).Err() +} + +func (a *Auth) Remaining(ctx context.Context, userID int64) (time.Duration, error) { + if a == nil || userID == 0 { + return 0, nil + } + return a.rdb.TTL(ctx, a.key(userID)).Result() +} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 98b903b..2f29cbe 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -45,6 +45,12 @@ spec: value: ":{{ .Values.service.port }}" - name: CONFIG_PATH value: /etc/telegram-gateway/bots.yaml + # Auth layer — voir factory/docs/adr/20260509-telegram-gateway-auth.md. + # AUTH_SECRET et ALLOWED_USERS arrivent via envFrom secretRef. + - name: REDIS_URL + value: {{ .Values.auth.redisURL | quote }} + - name: AUTH_SESSION_TTL + value: {{ .Values.auth.sessionTTL | quote }} envFrom: - secretRef: name: {{ .Values.secret.name }} diff --git a/chart/values.yaml b/chart/values.yaml index bb7e173..fde9de3 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -81,11 +81,29 @@ autoscaling: # Bot routing config — non-secret, becomes the bots.yaml ConfigMap entry. # Tokens & secret_token values live in a k8s Secret named `secret.name`. -# In Phase 1 the Secret is created out-of-band (kubectl create secret); in a -# later phase Vault will produce it via VSO (toggle `vault.enabled`). +# +# Auth gate (Phase 1.5, ADR factory/docs/adr/20260509-telegram-gateway-auth.md): +# - `requireAuth` defaults to **true** (secure by default). Add +# `requireAuth: false` only for bots you want to expose publicly. +# - For `handler: auth`, requireAuth is auto-forced to false (the auth bot +# can't gate itself or no one could ever authenticate). bots: factory: - handler: echo + handler: auth # principal bot — gère /auth, /whoami, /logout + # Exemple d'un bot gated (défaut) : + # pingbot: + # handler: echo + # + # Exemple d'un bot public (opt-out explicite) : + # statusbot: + # handler: echo + # requireAuth: false + +# Auth layer (Phase 1.5). REDIS_URL est passé en env clair (non secret). +# AUTH_SECRET et ALLOWED_USERS doivent vivre dans le Secret k8s `secret.name`. +auth: + redisURL: "redis://redis.tools.svc.cluster.local:6379/0" + sessionTTL: "24h" # k8s Secret consumed by `envFrom`. Phase 1: create it manually with kubectl. # kubectl -n telegram-gateway create secret generic telegram-gateway-bots \ diff --git a/config.go b/config.go index 6e4ff69..7274579 100644 --- a/config.go +++ b/config.go @@ -14,8 +14,13 @@ type Config struct { type BotConfig struct { Handler string `yaml:"handler"` - Token string `yaml:"-"` - Secret string `yaml:"-"` + // RequireAuth is *bool so "absent" is distinct from "false". When unset + // (nil) the registry treats it as true — secure by default. Explicit + // `requireAuth: false` is required to expose a bot publicly. + // Forced to false for handler=auth (chicken-and-egg). + RequireAuth *bool `yaml:"requireAuth,omitempty"` + Token string `yaml:"-"` + Secret string `yaml:"-"` } // LoadConfig reads the YAML routing config and merges per-bot secrets pulled diff --git a/go.mod b/go.mod index a0f0ece..294c1e5 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,13 @@ module github.com/arcodange/telegram-gateway -go 1.23 +go 1.24 -require gopkg.in/yaml.v3 v3.0.1 +require ( + github.com/redis/go-redis/v9 v9.19.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + go.uber.org/atomic v1.11.0 // indirect +) diff --git a/go.sum b/go.sum index a62c313..7b8ed36 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,25 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/handler_auth.go b/handler_auth.go new file mode 100644 index 0000000..ebc0d62 --- /dev/null +++ b/handler_auth.go @@ -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 , /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 — 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 ` 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 "@" 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 `"}) + } + if userID == 0 { + log.Printf("auth attempt user= 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 ` 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 +} diff --git a/handlers.go b/handlers.go index e87ad01..3cf1aca 100644 --- a/handlers.go +++ b/handlers.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "log" ) type Handler interface { @@ -10,22 +11,27 @@ type Handler interface { } type Bot struct { - Slug string - Token string - Secret string - Handler Handler + Slug string + Token string + Secret string + RequireAuth bool + Handler Handler } type Registry struct { bots map[string]Bot } -func NewRegistry(cfg *Config) (*Registry, error) { +// NewRegistry builds the bot routing map from the parsed YAML config + the +// per-bot secrets (already merged into BotConfig from env). It receives the +// shared TelegramClient and Auth so per-handler instances can use them. +// `auth` may be nil (no AUTH_SECRET set) ; in that case any bot configured +// with `handler: auth` or `requireAuth: true` is a fatal config error. +func NewRegistry(cfg *Config, tg *TelegramClient, auth *Auth) (*Registry, error) { if len(cfg.Bots) == 0 { return nil, fmt.Errorf("no bots configured") } - tg := NewTelegramClient() bots := make(map[string]Bot, len(cfg.Bots)) for slug, b := range cfg.Bots { @@ -38,10 +44,35 @@ func NewRegistry(cfg *Config) (*Registry, error) { return nil, fmt.Errorf("bot %s: secret missing", slug) } + // Default requireAuth = true (secure by default). Explicit + // `requireAuth: false` is required to expose a bot publicly. + requireAuth := true + if b.RequireAuth != nil { + requireAuth = *b.RequireAuth + } + + // Chicken-and-egg : the auth handler itself can't be gated, otherwise + // no one could ever authenticate. Force off and warn loudly. + if b.Handler == "auth" { + if requireAuth { + log.Printf("bot %s: handler=auth implies requireAuth=false (otherwise unreachable) — overriding", slug) + } + requireAuth = false + } + + if requireAuth && auth == nil { + return nil, fmt.Errorf("bot %s has requireAuth: true (default) but AUTH_SECRET is unset; either set AUTH_SECRET or add `requireAuth: false` to the bot config", slug) + } + var h Handler switch b.Handler { case "echo": h = &EchoHandler{tg: tg} + case "auth": + if auth == nil { + return nil, fmt.Errorf("bot %s uses handler=auth but AUTH_SECRET is unset", slug) + } + h = &AuthHandler{tg: tg, auth: auth} case "": return nil, fmt.Errorf("bot %s: handler missing", slug) default: @@ -49,10 +80,11 @@ func NewRegistry(cfg *Config) (*Registry, error) { } bots[slug] = Bot{ - Slug: slug, - Token: token, - Secret: secret, - Handler: h, + Slug: slug, + Token: token, + Secret: secret, + RequireAuth: requireAuth, + Handler: h, } } diff --git a/main.go b/main.go index 4c0eab5..c876c9f 100644 --- a/main.go +++ b/main.go @@ -44,14 +44,47 @@ func runServer() { log.Fatalf("load config: %v", err) } - registry, err := NewRegistry(cfg) + tg := NewTelegramClient() + + // Phase 1.5 — auth layer (Redis-backed sessions). See + // factory/docs/adr/20260509-telegram-gateway-auth.md. + authSecret := os.Getenv("AUTH_SECRET") + redisURL := envOr("REDIS_URL", "redis://redis.tools.svc.cluster.local:6379/0") + ttl := 24 * time.Hour + if raw := os.Getenv("AUTH_SESSION_TTL"); raw != "" { + if d, err := time.ParseDuration(raw); err == nil && d > 0 { + ttl = d + } else { + log.Printf("AUTH_SESSION_TTL=%q invalid, defaulting to 24h", raw) + } + } + var auth *Auth + if authSecret != "" { + var aerr error + auth, aerr = NewAuth(redisURL, authSecret, ttl) + if aerr != nil { + log.Fatalf("init auth: %v", aerr) + } + log.Printf("auth layer initialized (TTL=%s, redis=%s)", ttl, redisURL) + } else { + log.Print("AUTH_SECRET unset → auth layer disabled (no bot may have handler=auth or requireAuth: true)") + } + + allowlist := NewAllowlist(os.Getenv("ALLOWED_USERS")) + if allowlist.Open() { + log.Print("ALLOWED_USERS empty → allowlist open to all") + } else { + log.Printf("allowlist active (%d user(s) allowed)", allowlist.Size()) + } + + registry, err := NewRegistry(cfg, tg, auth) if err != nil { log.Fatalf("build registry: %v", err) } srv := &http.Server{ Addr: *addr, - Handler: NewServer(registry).Routes(), + Handler: NewServer(registry, auth, allowlist, tg).Routes(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, diff --git a/server.go b/server.go index 52f8560..540e5da 100644 --- a/server.go +++ b/server.go @@ -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 ` 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) } diff --git a/telegram.go b/telegram.go index 376a12b..4962a03 100644 --- a/telegram.go +++ b/telegram.go @@ -162,5 +162,42 @@ func (c *TelegramClient) GetWebhookInfo(ctx context.Context, token string) (*Web return &info, nil } +type DeleteMessageParams struct { + ChatID int64 `json:"chat_id"` + MessageID int64 `json:"message_id"` +} + +// DeleteMessage removes a message from a chat. Used as best-effort replay +// defense after a successful /auth (we delete the message that contained +// the secret). See factory/docs/adr/20260509-telegram-gateway-auth.md. +func (c *TelegramClient) DeleteMessage(ctx context.Context, token string, chatID, messageID int64) error { + body, err := json.Marshal(DeleteMessageParams{ChatID: chatID, MessageID: messageID}) + if err != nil { + return err + } + endpoint := fmt.Sprintf("%s/bot%s/deleteMessage", c.apiBase, token) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + var ar apiResponse + if jerr := json.Unmarshal(respBody, &ar); jerr != nil { + return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr) + } + if !ar.OK { + return fmt.Errorf("telegram deleteMessage error (code=%d): %s", ar.ErrorCode, ar.Description) + } + return nil +} + // botBuildURL is exposed for tests; not used directly. var _ = url.Parse diff --git a/telegram_types.go b/telegram_types.go index 35b46da..65d11fd 100644 --- a/telegram_types.go +++ b/telegram_types.go @@ -46,6 +46,21 @@ func (u Update) ChatID() (int64, bool) { return 0, false } +// UserID extracts the Telegram user ID (`from.id`) from whichever sub-payload +// is set. Used by the auth layer (factory bot session, requireAuth gate, allowlist). +// See factory/docs/adr/20260509-telegram-gateway-auth.md. +func (u Update) UserID() (int64, bool) { + switch { + case u.Message != nil && u.Message.From != nil: + return u.Message.From.ID, true + case u.EditedMessage != nil && u.EditedMessage.From != nil: + return u.EditedMessage.From.ID, true + case u.CallbackQuery != nil && u.CallbackQuery.From != nil: + return u.CallbackQuery.From.ID, true + } + return 0, false +} + func (u Update) Text() string { switch { case u.Message != nil: