// Voir factory/doc/adr/20260509-telegram-gateway-auth.md package main import ( "context" "crypto/subtle" "fmt" "time" "github.com/redis/go-redis/v9" ) // Auth wraps a Redis client and exposes the session primitives the gateway // needs : IsAuthed / Login / Logout / Remaining. The session key has the form // `tg-gw:auth:` and a configurable TTL (24h by default). type Auth struct { rdb *redis.Client prefix string ttl time.Duration secret string } func NewAuth(redisURL, secret string, ttl time.Duration) (*Auth, error) { if secret == "" { return nil, fmt.Errorf("AUTH_SECRET is required for the auth layer") } if redisURL == "" { return nil, fmt.Errorf("REDIS_URL is required for the auth layer") } opts, err := redis.ParseURL(redisURL) if err != nil { return nil, fmt.Errorf("parse REDIS_URL: %w", err) } rdb := redis.NewClient(opts) pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := rdb.Ping(pingCtx).Err(); err != nil { return nil, fmt.Errorf("redis ping: %w", err) } return &Auth{rdb: rdb, prefix: "tg-gw:auth:", ttl: ttl, secret: secret}, nil } func (a *Auth) key(userID int64) string { return fmt.Sprintf("%s%d", a.prefix, userID) } func (a *Auth) IsAuthed(ctx context.Context, userID int64) (bool, error) { if a == nil || userID == 0 { return false, nil } n, err := a.rdb.Exists(ctx, a.key(userID)).Result() if err != nil { return false, err } return n > 0, nil } // Login compares the provided code in constant time and, on match, sets the // session key with the configured TTL. Returns ok=false on a wrong code (no // error). The bool/error split lets the caller distinguish "wrong code" // (user-facing) from "redis is down" (operator-facing). func (a *Auth) Login(ctx context.Context, userID int64, providedSecret string) (bool, error) { if a == nil { return false, fmt.Errorf("auth not initialized") } if userID == 0 { return false, fmt.Errorf("missing user id") } if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(a.secret)) != 1 { return false, nil } if err := a.rdb.Set(ctx, a.key(userID), "1", a.ttl).Err(); err != nil { return false, fmt.Errorf("redis SET: %w", err) } return true, nil } func (a *Auth) Logout(ctx context.Context, userID int64) error { if a == nil || userID == 0 { return nil } return a.rdb.Del(ctx, a.key(userID)).Err() } func (a *Auth) Remaining(ctx context.Context, userID int64) (time.Duration, error) { if a == nil || userID == 0 { return 0, nil } return a.rdb.TTL(ctx, a.key(userID)).Result() }