// Command totp-test runs an interactive TOTP enrollment and verification flow // against local Redis + in-memory profile (single process). // // Prerequisites: // // make deps-up // make setup-dev # ensure Member.TOTP.SecretKEK is set // // Usage: // // make totp-test // go run ./cmd/totp-test -f etc/gateway.dev.yaml package main import ( "bufio" "context" "flag" "fmt" "net/url" "os" "strings" "gateway/internal/config" redislib "gateway/internal/library/redis" domusecase "gateway/internal/model/member/domain/usecase" memberusecase "gateway/internal/model/member/usecase" "github.com/skip2/go-qrcode" "github.com/zeromicro/go-zero/core/conf" ) const ( stepFlow = "flow" stepEnroll = "enroll" stepConfirm = "confirm" stepVerify = "verify" stepStatus = "status" stepDisable = "disable" ) var ( configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") stepFlag = flag.String("step", stepFlow, "step: flow, enroll, confirm, verify, status, disable") tenantID = flag.String("tenant", "totp-test", "tenant_id") uidFlag = flag.String("uid", "totp-test-uid", "uid") account = flag.String("account", "totp-test@example.com", "account label shown in Authenticator") codeFlag = flag.String("code", "", "TOTP code (non-interactive confirm/verify)") kekFlag = flag.String("kek", "", "override Member.TOTP.SecretKEK (hex or base64)") ) func main() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: totp-test [options]\n\n") fmt.Fprintf(os.Stderr, "Interactive TOTP test for Google Authenticator / Authy.\n") fmt.Fprintf(os.Stderr, "Default step=flow guides enroll → confirm → verify in one process.\n\n") fmt.Fprintf(os.Stderr, "Examples:\n") fmt.Fprintf(os.Stderr, " totp-test\n") fmt.Fprintf(os.Stderr, " totp-test -step status\n") fmt.Fprintf(os.Stderr, " totp-test -step confirm -code 482913\n") flag.PrintDefaults() } flag.Parse() code, err := run() if err != nil { fmt.Fprintln(os.Stderr, err) } if code != 0 { os.Exit(code) } } func run() (int, error) { step := strings.TrimSpace(*stepFlag) if step == "" { step = stepFlow } var c config.Config conf.MustLoad(*configFile, &c) if c.Redis.Host == "" { return 1, fmt.Errorf("totp-test: Redis.Host is empty (run: make deps-up)") } kek := strings.TrimSpace(*kekFlag) if kek == "" { kek = strings.TrimSpace(c.Member.TOTP.SecretKEK) } if kek == "" { return 1, fmt.Errorf("totp-test: Member.TOTP.SecretKEK is empty; set it in %s or pass -kek", *configFile) } c.Member.TOTP.SecretKEK = kek ctx := context.Background() rds, err := redislib.NewClient(c.Redis) if err != nil { return 1, fmt.Errorf("totp-test: redis: %w", err) } mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ Redis: rds, Config: c.Member, }) if err != nil { return 1, fmt.Errorf("totp-test: member: %w", err) } if mod.TOTP == nil { return 1, fmt.Errorf("totp-test: TOTP usecase not wired (invalid SecretKEK?)") } env := &session{ ctx: ctx, tenant: *tenantID, uid: *uidFlag, totp: mod.TOTP, in: bufio.NewReader(os.Stdin), } switch step { case stepFlow: if err := env.runFlow(); err != nil { return 1, fmt.Errorf("FAIL: %w", err) } case stepEnroll: if err := env.doEnroll(); err != nil { return 1, fmt.Errorf("FAIL: %w", err) } case stepConfirm: if err := env.doConfirm(strings.TrimSpace(*codeFlag)); err != nil { return 1, fmt.Errorf("FAIL: %w", err) } case stepVerify: if err := env.doVerify(strings.TrimSpace(*codeFlag)); err != nil { return 1, fmt.Errorf("FAIL: %w", err) } case stepStatus: if err := env.doStatus(); err != nil { return 1, fmt.Errorf("FAIL: %w", err) } case stepDisable: if err := env.doDisable(); err != nil { return 1, fmt.Errorf("FAIL: %w", err) } default: flag.Usage() return 2, fmt.Errorf("totp-test: unknown step %q", step) } fmt.Println("OK") return 0, nil } type session struct { ctx context.Context tenant string uid string totp domusecase.TOTPUseCase in *bufio.Reader } func (s *session) runFlow() error { status, err := s.totp.Status(s.ctx, s.tenant, s.uid) if err != nil { return err } if status.Enrolled { fmt.Println("Already enrolled for this tenant/uid.") if err := s.doStatus(); err != nil { return err } fmt.Println() fmt.Println("Proceeding to verify-only (skip enroll/confirm).") } else { if err := s.doEnroll(); err != nil { return err } fmt.Println() confirmCode, err := s.readCode("Enter the 6-digit code from Google Authenticator to confirm enrollment: ") if err != nil { return err } if err := s.doConfirm(confirmCode); err != nil { return err } fmt.Println() fmt.Println("Wait for the Authenticator code to refresh (up to 30s), then verify step-up.") } verifyCode, err := s.readCode("Enter a fresh 6-digit code to verify (step-up): ") if err != nil { return err } if err := s.doVerify(verifyCode); err != nil { return err } fmt.Println() fmt.Println("Testing replay protection with the same code (should fail)...") if err := s.doVerify(verifyCode); err == nil { return fmt.Errorf("expected replay failure but verify succeeded") } fmt.Println("Replay correctly rejected.") return nil } func (s *session) doEnroll() error { start, err := s.totp.StartEnroll(s.ctx, s.tenant, s.uid, *account) if err != nil { return err } secret, err := secretFromOtpauthURL(start.OtpauthURL) if err != nil { return err } fmt.Println("=== TOTP Enrollment ===") fmt.Printf("tenant=%s uid=%s\n", s.tenant, s.uid) fmt.Printf("issuer=%s account=%s digits=%d period=%ds expires_in=%ds\n", start.Issuer, start.Account, start.Digits, start.PeriodSec, start.ExpiresIn) fmt.Println() fmt.Println("Option A — scan QR code with Google Authenticator:") fmt.Println() if err := printTerminalQR(start.OtpauthURL); err != nil { fmt.Fprintf(os.Stderr, "warn: QR render failed: %v\n", err) } fmt.Println() fmt.Println("Option B — enter setup key manually in Google Authenticator:") fmt.Printf(" Type: Time based\n") fmt.Printf(" Account: %s\n", start.Account) fmt.Printf(" Issuer: %s\n", start.Issuer) fmt.Printf(" Secret key: %s\n", secret) fmt.Println() fmt.Println("otpauth URL (for debugging):") fmt.Println(start.OtpauthURL) fmt.Println() fmt.Printf("Complete enrollment within %d seconds.\n", start.ExpiresIn) return nil } func (s *session) doConfirm(code string) error { if code == "" { var err error code, err = s.readCode("Enter the 6-digit code from Google Authenticator: ") if err != nil { return err } } backup, err := s.totp.ConfirmEnroll(s.ctx, s.tenant, s.uid, code) if err != nil { return err } fmt.Println("Enrollment confirmed.") fmt.Printf("backup_codes (%d, save these — shown once):\n", len(backup)) for i, c := range backup { fmt.Printf(" [%02d] %s\n", i+1, c) } return s.doStatus() } func (s *session) doVerify(code string) error { if code == "" { var err error code, err = s.readCode("Enter a 6-digit TOTP code (or backup code): ") if err != nil { return err } } if err := s.totp.VerifyCode(s.ctx, s.tenant, s.uid, code); err != nil { return err } fmt.Println("VerifyCode: success") return nil } func (s *session) doStatus() error { status, err := s.totp.Status(s.ctx, s.tenant, s.uid) if err != nil { return err } fmt.Printf("status enrolled=%t backup_codes_remaining=%d", status.Enrolled, status.BackupCodesRemaining) if status.Enrolled { fmt.Printf(" enrolled_at=%d", status.EnrolledAt) } fmt.Println() return nil } func (s *session) doDisable() error { if err := s.totp.Disable(s.ctx, s.tenant, s.uid); err != nil { return err } fmt.Println("TOTP disabled.") return s.doStatus() } func (s *session) readCode(prompt string) (string, error) { fmt.Print(prompt) line, err := s.in.ReadString('\n') if err != nil { return "", fmt.Errorf("read code: %w", err) } code := strings.TrimSpace(line) if code == "" { return "", fmt.Errorf("code is empty") } return code, nil } func secretFromOtpauthURL(raw string) (string, error) { u, err := url.Parse(raw) if err != nil { return "", fmt.Errorf("parse otpauth url: %w", err) } secret := strings.TrimSpace(u.Query().Get("secret")) if secret == "" { return "", fmt.Errorf("otpauth url missing secret parameter") } return secret, nil } func printTerminalQR(content string) error { qr, err := qrcode.New(content, qrcode.Medium) if err != nil { return err } fmt.Print(qr.ToSmallString(false)) return nil }