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. 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) : Steps (humain ou session Claude avec accès au cluster + au repo) :
1. **@BotFather crée le bot**, noter le TOKEN.
```bash ```bash
# 1. @BotFather crée le bot, noter le TOKEN
TOKEN='1234:AAA...' TOKEN='1234:AAA...'
SLUG='monbot' # kebab-case, [a-z0-9-]+ SLUG='monbot' # kebab-case, [a-z0-9-]+
ENV_SLUG=$(echo "$SLUG" | tr 'a-z-' 'A-Z_') # ex: monbot → MONBOT ENV_SLUG=$(echo "$SLUG" | tr 'a-z-' 'A-Z_') # ex: monbot → MONBOT
SECRET=$(openssl rand -hex 32) SECRET=$(openssl rand -hex 32)
```
# 2. Patcher le Secret cluster (ajoute les 2 clés sans toucher aux existantes) 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="[ 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}_TOKEN\",\"value\":\"$(echo -n "$TOKEN" | base64)\"},
{\"op\":\"add\",\"path\":\"/data/BOT_${ENV_SLUG}_SECRET\",\"value\":\"$(echo -n "$SECRET" | 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: 3. **Déclarer le bot** dans `chart/values.yaml` sous `bots:` :
# (édite le fichier puis push)
```yaml
bots:
monbot:
handler: echo
# requireAuth: true (implicite — défaut sécurisé)
```
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 cd /Users/gabrielradureau/Work/Vibe/telegram-gateway
# ajouter sous "bots:" :
# monbot:
# handler: echo
git add chart/values.yaml git add chart/values.yaml
git commit -m "bots: add $SLUG" git commit -m "bots: add $SLUG"
git push git push
# 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 restart deploy/telegram-gateway
kubectl -n telegram-gateway rollout status deploy/telegram-gateway kubectl -n telegram-gateway rollout status deploy/telegram-gateway
```
# 5. Enregistrer le webhook côté Telegram 5. **Enregistrer le webhook côté Telegram** :
```bash
export BOT_${ENV_SLUG}_TOKEN="$TOKEN" export BOT_${ENV_SLUG}_TOKEN="$TOKEN"
export BOT_${ENV_SLUG}_SECRET="$SECRET" export BOT_${ENV_SLUG}_SECRET="$SECRET"
make setwebhook SLUG="$SLUG" BASE_URL=https://tg.arcodange.fr make setwebhook SLUG="$SLUG" BASE_URL=https://tg.arcodange.fr
# 6. Test : envoie un message au bot, attends < 2s pour l'echo
``` ```
**Limite Phase 1** : tous les bots ont le handler `echo`. Pas encore de routage vers une logique métier différente par bot. 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 }}" value: ":{{ .Values.service.port }}"
- name: CONFIG_PATH - name: CONFIG_PATH
value: /etc/telegram-gateway/bots.yaml 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: envFrom:
- secretRef: - secretRef:
name: {{ .Values.secret.name }} name: {{ .Values.secret.name }}

View File

@@ -81,11 +81,29 @@ autoscaling:
# Bot routing config — non-secret, becomes the bots.yaml ConfigMap entry. # Bot routing config — non-secret, becomes the bots.yaml ConfigMap entry.
# Tokens & secret_token values live in a k8s Secret named `secret.name`. # 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: bots:
factory: 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. # k8s Secret consumed by `envFrom`. Phase 1: create it manually with kubectl.
# kubectl -n telegram-gateway create secret generic telegram-gateway-bots \ # kubectl -n telegram-gateway create secret generic telegram-gateway-bots \

View File

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

12
go.mod
View File

@@ -1,5 +1,13 @@
module github.com/arcodange/telegram-gateway 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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 ( import (
"context" "context"
"fmt" "fmt"
"log"
) )
type Handler interface { type Handler interface {
@@ -13,6 +14,7 @@ type Bot struct {
Slug string Slug string
Token string Token string
Secret string Secret string
RequireAuth bool
Handler Handler Handler Handler
} }
@@ -20,12 +22,16 @@ type Registry struct {
bots map[string]Bot 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 { if len(cfg.Bots) == 0 {
return nil, fmt.Errorf("no bots configured") return nil, fmt.Errorf("no bots configured")
} }
tg := NewTelegramClient()
bots := make(map[string]Bot, len(cfg.Bots)) bots := make(map[string]Bot, len(cfg.Bots))
for slug, b := range 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) 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 var h Handler
switch b.Handler { switch b.Handler {
case "echo": case "echo":
h = &EchoHandler{tg: tg} 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 "": case "":
return nil, fmt.Errorf("bot %s: handler missing", slug) return nil, fmt.Errorf("bot %s: handler missing", slug)
default: default:
@@ -52,6 +83,7 @@ func NewRegistry(cfg *Config) (*Registry, error) {
Slug: slug, Slug: slug,
Token: token, Token: token,
Secret: secret, Secret: secret,
RequireAuth: requireAuth,
Handler: h, Handler: h,
} }
} }

37
main.go
View File

@@ -44,14 +44,47 @@ func runServer() {
log.Fatalf("load config: %v", err) 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 { if err != nil {
log.Fatalf("build registry: %v", err) log.Fatalf("build registry: %v", err)
} }
srv := &http.Server{ srv := &http.Server{
Addr: *addr, Addr: *addr,
Handler: NewServer(registry).Routes(), Handler: NewServer(registry, auth, allowlist, tg).Routes(),
ReadHeaderTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,

View File

@@ -10,10 +10,13 @@ import (
type Server struct { type Server struct {
registry *Registry registry *Registry
auth *Auth
allowlist Allowlist
tg *TelegramClient
} }
func NewServer(r *Registry) *Server { func NewServer(r *Registry, auth *Auth, allow Allowlist, tg *TelegramClient) *Server {
return &Server{registry: r} return &Server{registry: r, auth: auth, allowlist: allow, tg: tg}
} }
func (s *Server) Routes() http.Handler { func (s *Server) Routes() http.Handler {
@@ -67,6 +70,39 @@ func (s *Server) botWebhook(w http.ResponseWriter, r *http.Request) {
return 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 { if err := bot.Handler.Handle(r.Context(), update, bot); err != nil {
log.Printf("bot=%s update=%d handler error: %v", slug, update.UpdateID, err) 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 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. // botBuildURL is exposed for tests; not used directly.
var _ = url.Parse var _ = url.Parse

View File

@@ -46,6 +46,21 @@ func (u Update) ChatID() (int64, bool) {
return 0, false 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 { func (u Update) Text() string {
switch { switch {
case u.Message != nil: case u.Message != nil: