Phase 1.5 — auth layer (Redis sessions, allowlist, requireAuth)
Some checks failed
Docker Build / build-and-push-image (push) Failing after 18s
Some checks failed
Docker Build / build-and-push-image (push) Failing after 18s
Adds an authentication layer in front of the bot handlers : - Auth handler on the principal bot (@arcodange_factory_bot, slug factory) parses /start, /auth <code>, /whoami, /logout. On a successful /auth, the message containing the code is best-effort deleted from the user's chat (replay defense). - Redis-backed sessions (key tg-gw:auth:<from.id>, TTL 24h, configurable via AUTH_SESSION_TTL). Constant-time secret compare via crypto/subtle. - ALLOWED_USERS env (CSV of Telegram user IDs) — silent-drops anyone not in the list before the auth gate runs. - New per-bot field 'requireAuth' (pointer-bool). Default = true (secure by default). Auto-forced to false for handler=auth (chicken-and-egg). - Server gates: allowlist first, then requireAuth before handler dispatch. - Fail-at-startup if a bot is configured with handler=auth or requireAuth: true while AUTH_SECRET is unset. Design: factory/docs/adr/20260509-telegram-gateway-auth.md (in factory PR). User docs: AUTH.md (new), HOWTO_ADD_BOT.md (Cas 2 updated for default true and gated flow). New deps: github.com/redis/go-redis/v9. Refs ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 1.5.
This commit is contained in:
106
AUTH.md
Normal file
106
AUTH.md
Normal 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.
|
||||
@@ -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
55
allowlist.go
Normal 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
92
auth.go
Normal 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()
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
12
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
|
||||
)
|
||||
|
||||
22
go.sum
22
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=
|
||||
|
||||
135
handler_auth.go
Normal file
135
handler_auth.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Voir factory/docs/adr/20260509-telegram-gateway-auth.md
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthHandler is the handler for the principal bot (factory). It accepts
|
||||
// the auth-related commands (/start, /auth <code>, /whoami, /logout) and
|
||||
// falls back to a help message for anything else. On a successful /auth,
|
||||
// the original chat message is best-effort deleted so the secret doesn't
|
||||
// linger in the user's chat history.
|
||||
type AuthHandler struct {
|
||||
tg *TelegramClient
|
||||
auth *Auth
|
||||
}
|
||||
|
||||
const helpMessage = "Commandes disponibles :\n" +
|
||||
" /auth <code> — ouvrir une session (24 h)\n" +
|
||||
" /whoami — afficher l'état de session\n" +
|
||||
" /logout — fermer la session\n"
|
||||
|
||||
const startMessage = "Bonjour 👋\n\n" +
|
||||
"Je suis le bot d'authentification du gateway Arcodange.\n" +
|
||||
"Envoie `/auth <code>` pour ouvrir une session, puis utilise les autres bots du gateway normalement.\n\n" + helpMessage
|
||||
|
||||
func (h *AuthHandler) Handle(ctx context.Context, update Update, bot Bot) error {
|
||||
chatID, ok := update.ChatID()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
userID, _ := update.UserID()
|
||||
text := strings.TrimSpace(update.Text())
|
||||
|
||||
switch {
|
||||
case text == "/start" || text == "/start@"+bot.Slug:
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: startMessage})
|
||||
|
||||
case strings.HasPrefix(text, "/auth"):
|
||||
provided := strings.TrimSpace(strings.TrimPrefix(text, "/auth"))
|
||||
// strip a possible "@<botname>" suffix on the command (e.g. /auth@arcodange_factory_bot s3cr3t)
|
||||
if at := strings.IndexAny(provided, " "); at > 0 && strings.HasPrefix(provided, "@") {
|
||||
provided = strings.TrimSpace(provided[at:])
|
||||
}
|
||||
return h.handleAuthCommand(ctx, update, bot, chatID, userID, provided)
|
||||
|
||||
case text == "/whoami" || strings.HasPrefix(text, "/whoami@"):
|
||||
return h.handleWhoami(ctx, bot, chatID, userID)
|
||||
|
||||
case text == "/logout" || strings.HasPrefix(text, "/logout@"):
|
||||
return h.handleLogout(ctx, bot, chatID, userID)
|
||||
|
||||
default:
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: helpMessage})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) handleAuthCommand(ctx context.Context, update Update, bot Bot, chatID, userID int64, provided string) error {
|
||||
if provided == "" {
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Usage : `/auth <code>`"})
|
||||
}
|
||||
if userID == 0 {
|
||||
log.Printf("auth attempt user=<unknown> result=skip (no from.id)")
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Impossible d'identifier l'utilisateur."})
|
||||
}
|
||||
|
||||
ok, err := h.auth.Login(ctx, userID, provided)
|
||||
if err != nil {
|
||||
log.Printf("auth attempt user=%d result=error err=%v", userID, err)
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Erreur interne, réessaie plus tard."})
|
||||
}
|
||||
if !ok {
|
||||
log.Printf("auth attempt user=%d result=fail", userID)
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "❌ Mauvais code."})
|
||||
}
|
||||
|
||||
log.Printf("auth attempt user=%d result=ok", userID)
|
||||
|
||||
// Replay defense — best-effort, ignore errors (insufficient permissions etc.)
|
||||
if msgID := messageID(update); msgID != 0 {
|
||||
if delErr := h.tg.DeleteMessage(ctx, bot.Token, chatID, msgID); delErr != nil {
|
||||
log.Printf("deleteMessage best-effort failed user=%d: %v", userID, delErr)
|
||||
}
|
||||
}
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: "✅ Authentifié pour 24 h. Tu peux maintenant utiliser les autres bots du gateway.",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) handleWhoami(ctx context.Context, bot Bot, chatID, userID int64) error {
|
||||
if userID == 0 {
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Impossible d'identifier l'utilisateur."})
|
||||
}
|
||||
authed, _ := h.auth.IsAuthed(ctx, userID)
|
||||
if !authed {
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: fmt.Sprintf("user=%d : non authentifié.\nUtilise `/auth <code>` pour ouvrir une session.", userID),
|
||||
})
|
||||
}
|
||||
ttl, _ := h.auth.Remaining(ctx, userID)
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: fmt.Sprintf("user=%d : ✅ authentifié, reste %s.", userID, ttl.Round(time.Second)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) handleLogout(ctx context.Context, bot Bot, chatID, userID int64) error {
|
||||
if userID == 0 {
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Impossible d'identifier l'utilisateur."})
|
||||
}
|
||||
if err := h.auth.Logout(ctx, userID); err != nil {
|
||||
log.Printf("logout user=%d err=%v", userID, err)
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Erreur interne, réessaie plus tard."})
|
||||
}
|
||||
log.Printf("logout user=%d", userID)
|
||||
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: "Déconnecté."})
|
||||
}
|
||||
|
||||
// messageID extracts the original Telegram message ID from an Update so we
|
||||
// can delete it on a successful /auth (replay defense).
|
||||
func messageID(u Update) int64 {
|
||||
switch {
|
||||
case u.Message != nil:
|
||||
return u.Message.MessageID
|
||||
case u.EditedMessage != nil:
|
||||
return u.EditedMessage.MessageID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
36
handlers.go
36
handlers.go
@@ -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
37
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,
|
||||
|
||||
40
server.go
40
server.go
@@ -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)
|
||||
}
|
||||
|
||||
37
telegram.go
37
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user