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:
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()
|
||||
}
|
||||
Reference in New Issue
Block a user