331 lines
8.4 KiB
Go
331 lines
8.4 KiB
Go
// 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
|
|
}
|