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

106
AUTH.md Normal file
View File

@@ -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 <code>`. 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 <code>` (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 <code>`.
## Côté opérateur
### Définir / changer le code `AUTH_SECRET`
```bash
# Choisir un code fort (ex : openssl rand -hex 16)
SECRET='<chosen-code>'
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.

View File

@@ -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 <code>` 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="[
```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)
```
2. **Patcher le Secret cluster** (ajoute les 2 clés sans toucher aux existantes) :
```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)\"}
]"
]"
```
# 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
3. **Déclarer le bot** dans `chart/values.yaml` sous `bots:` :
# 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
```yaml
bots:
monbot:
handler: echo
# requireAuth: true (implicite — défaut sécurisé)
```
# 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
Pour un bot public (notifications status, etc.), opt-out explicite :
# 6. Test : envoie un message au bot, attends < 2s pour l'echo
```
```yaml
bots:
statusbot:
handler: echo
requireAuth: false
```
**Limite Phase 1** : tous les bots ont le handler `echo`. Pas encore de routage vers une logique métier différente par bot.
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 <code>` 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.
---

55
allowlist.go Normal file
View File

@@ -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) }

92
auth.go Normal file
View File

@@ -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:<telegramUserID>` 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()
}

View File

@@ -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 }}

View File

@@ -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 \

View File

@@ -14,6 +14,11 @@ type Config struct {
type BotConfig struct {
Handler string `yaml:"handler"`
// 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:"-"`
}

12
go.mod
View File

@@ -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
)

22
go.sum
View File

@@ -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=

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
}

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"log"
)
type Handler interface {
@@ -13,6 +14,7 @@ type Bot struct {
Slug string
Token string
Secret string
RequireAuth bool
Handler Handler
}
@@ -20,12 +22,16 @@ 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:
@@ -52,6 +83,7 @@ func NewRegistry(cfg *Config) (*Registry, error) {
Slug: slug,
Token: token,
Secret: secret,
RequireAuth: requireAuth,
Handler: h,
}
}

37
main.go
View File

@@ -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,

View File

@@ -10,10 +10,13 @@ import (
type Server struct {
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)
}

View File

@@ -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

View File

@@ -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: