Explorar el Código

feat:添加FWH-TOTP验证码网页显示功能

Jerry Liang hace 1 mes
padre
commit
1285154d48

+ 6 - 0
deploy/config.env.example

@@ -55,6 +55,12 @@ FWH_PASSWORD="CHANGE_ME"
 FWH_TOTP_SECRET=""
 # 登录接口接收验证码的 JSON 字段名;启用 TOTP 时必填。
 FWH_TOTP_FIELD=""
+# 可选:设置后在独立端口启用 /fwh-totp 页面(Basic Auth 用户名固定为 fwh)。
+# 请使用独立强密码,勿与 FWH_TOTP_SECRET 或 FWH_PASSWORD 相同。
+FWH_TOTP_VIEW_PASSWORD=""
+# TOTP 页面独立监听地址和端口(默认仅本机访问)。
+FWH_TOTP_VIEW_HOST="127.0.0.1"
+FWH_TOTP_VIEW_PORT="8303"
 
 # ---------- FWH API(测试,可选)----------
 # TODO: [可选] 测试环境 FWH 账号,仅在需要连测试服时填写

+ 13 - 0
service/README.md

@@ -96,3 +96,16 @@ Database / Cache (Redis) / 外部服务 (FWH-API)
 | POSTGRES_DSN | PGSQL数据库连接配置,DSN格式,本地保存数据信息。 |
 | MONGO_DSN | MONGO数据库连接配置,DSN格式,本地保存数据信息。 |
 | REDIS_ADDR | REDIS本地缓存服务器的域名或IP地址,默认"localhost" |
+
+## FWH TOTP 验证码页面
+
+在后端配置中设置 `FWH_TOTP_SECRET`、独立的 `FWH_TOTP_VIEW_PASSWORD`,并按需设置
+`FWH_TOTP_VIEW_PORT`(默认 `8303`,必须与 `APP_PORT` 不同)。默认绑定地址由
+`FWH_TOTP_VIEW_HOST=127.0.0.1` 指定。重启后访问
+`http://127.0.0.1:<FWH_TOTP_VIEW_PORT>/fwh-totp/`。浏览器会要求输入 HTTP Basic Auth
+凭据:用户名固定为 `fwh`,密码为 `FWH_TOTP_VIEW_PASSWORD`。页面显示当前六位验证码和
+剩余秒数,并在验证码切换时自动更新。密钥或页面密码为空时,独立端口不会启动。
+
+从其他电脑访问时,可以通过 HTTPS 反向代理或 SSH 端口转发接入;也可以把
+`FWH_TOTP_VIEW_HOST` 改为服务器网卡地址。不要通过明文 HTTP 在公网传输 Basic Auth
+凭据和验证码。

+ 32 - 24
service/config/config.go

@@ -9,18 +9,21 @@ import (
 )
 
 type AppConfig struct {
-	AppEnv          string
-	AppPort         string
-	LogLevel        string
-	RedisAddr       string
-	MySQLDSN        string
-	PostgresDSN     string
-	MongoDSN        string
-	MinIoEndpoint   string
-	MinioAccessKey  string
-	MinioSecretKey  string
-	MinioBucketName string
-	AsynqmonPort    string
+	AppEnv           string
+	AppPort          string
+	LogLevel         string
+	RedisAddr        string
+	MySQLDSN         string
+	PostgresDSN      string
+	MongoDSN         string
+	MinIoEndpoint    string
+	MinioAccessKey   string
+	MinioSecretKey   string
+	MinioBucketName  string
+	AsynqmonPort     string
+	TotpViewPassword string
+	TotpViewHost     string
+	TotpViewPort     string
 }
 
 type FwhApiConfig struct {
@@ -127,6 +130,8 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 	v.SetDefault("MINIO_SECRET_KEY", "fwh-toolbox")
 	v.SetDefault("MINIO_BUCKET_NAME", "fwh-toolbox")
 	v.SetDefault("ASYNQMON_PORT", "8081")
+	v.SetDefault("FWH_TOTP_VIEW_PORT", "8303")
+	v.SetDefault("FWH_TOTP_VIEW_HOST", "127.0.0.1")
 	// FWH API
 	v.SetDefault("FWH_ENERGY", "energy.franklinwh.com")
 	v.SetDefault("FWH_OSS", "oss.franklinwh.com")
@@ -176,18 +181,21 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 
 	return &Config{
 		App: AppConfig{
-			AppEnv:          v.GetString("APP_ENV"),
-			AppPort:         v.GetString("APP_PORT"),
-			LogLevel:        v.GetString("LOG_LEVEL"),
-			RedisAddr:       v.GetString("REDIS_ADDR"),
-			MySQLDSN:        v.GetString("MYSQL_DSN"),
-			PostgresDSN:     v.GetString("POSTGRES_DSN"),
-			MongoDSN:        v.GetString("MONGO_DSN"),
-			MinIoEndpoint:   v.GetString("MINIO_ENDPOINT"),
-			MinioAccessKey:  v.GetString("MINIO_ACCESS_KEY"),
-			MinioSecretKey:  v.GetString("MINIO_SECRET_KEY"),
-			MinioBucketName: v.GetString("MINIO_BUCKET_NAME"),
-			AsynqmonPort:    v.GetString("ASYNQMON_PORT"),
+			AppEnv:           v.GetString("APP_ENV"),
+			AppPort:          v.GetString("APP_PORT"),
+			LogLevel:         v.GetString("LOG_LEVEL"),
+			RedisAddr:        v.GetString("REDIS_ADDR"),
+			MySQLDSN:         v.GetString("MYSQL_DSN"),
+			PostgresDSN:      v.GetString("POSTGRES_DSN"),
+			MongoDSN:         v.GetString("MONGO_DSN"),
+			MinIoEndpoint:    v.GetString("MINIO_ENDPOINT"),
+			MinioAccessKey:   v.GetString("MINIO_ACCESS_KEY"),
+			MinioSecretKey:   v.GetString("MINIO_SECRET_KEY"),
+			MinioBucketName:  v.GetString("MINIO_BUCKET_NAME"),
+			AsynqmonPort:     v.GetString("ASYNQMON_PORT"),
+			TotpViewPassword: v.GetString("FWH_TOTP_VIEW_PASSWORD"),
+			TotpViewHost:     v.GetString("FWH_TOTP_VIEW_HOST"),
+			TotpViewPort:     v.GetString("FWH_TOTP_VIEW_PORT"),
 		},
 		FwhApi: FwhApiConfig{
 			FwhEnergy:     v.GetString("FWH_ENERGY"),

+ 21 - 0
service/main.go

@@ -13,6 +13,7 @@ import (
 	"fwh-toolbox-server/utils/casdoor"
 	"fwh-toolbox-server/utils/minio"
 	"log"
+	"net"
 	"net/http"
 	"os"
 	"os/signal"
@@ -108,6 +109,23 @@ func main() {
 
 	routes.Setup(app)
 
+	// TOTP 页面使用独立端口,不占用主 APP 的路由。
+	var totpApp *fiber.App
+	if cfg.FwhApi.FwhTotpSecret != "" && cfg.App.TotpViewPassword != "" {
+		if cfg.App.TotpViewPort == cfg.App.AppPort {
+			panic("FWH_TOTP_VIEW_PORT must differ from APP_PORT")
+		}
+		totpApp = fiber.New()
+		routes.TotpViewSetup(totpApp, cfg.FwhApi.FwhTotpSecret, cfg.App.TotpViewPassword)
+		go func() {
+			addr := net.JoinHostPort(cfg.App.TotpViewHost, cfg.App.TotpViewPort)
+			if err := totpApp.Listen(addr); err != nil {
+				zap.L().Error("TOTP view port listening failed", zap.String("addr", addr), zap.Error(err))
+				panic(err)
+			}
+		}()
+	}
+
 	// 初始化 AI Chat (ACP Client)
 	controllers.InitAIChat(".", cfg.Kiro.RunAsUser, cfg.Kiro.Home, cfg.Kiro.DebugLog)
 
@@ -167,6 +185,9 @@ func main() {
 		signal.Notify(sigs, unix.SIGTERM, unix.SIGINT)
 		<-sigs
 		log.Println("fiber shutdown")
+		if totpApp != nil {
+			_ = totpApp.Shutdown()
+		}
 		app.Shutdown()
 	}()
 

+ 5 - 0
service/repositories/api/fwh_pro/totp.go

@@ -28,3 +28,8 @@ func generateTOTP(secret string, now time.Time) (string, error) {
 	value := binary.BigEndian.Uint32(digest[offset:offset+4]) & 0x7fffffff
 	return fmt.Sprintf("%06d", value%1000000), nil
 }
+
+// GenerateTOTP uses the same profile as the FWH login request.
+func GenerateTOTP(secret string, now time.Time) (string, error) {
+	return generateTOTP(secret, now)
+}

+ 72 - 0
service/routes/totpViewRoutes.go

@@ -0,0 +1,72 @@
+package routes
+
+import (
+	"crypto/subtle"
+	"embed"
+	"encoding/base64"
+	"fwh-toolbox-server/repositories/api/fwh_pro"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+//go:embed totp_view.html
+var totpViewFiles embed.FS
+
+// TotpViewSetup registers the protected page on a dedicated Fiber app.
+// An unset password or TOTP secret disables both endpoints.
+func TotpViewSetup(app *fiber.App, secret, password string) {
+	if secret == "" || password == "" {
+		return
+	}
+
+	view := app.Group("/fwh-totp", func(c *fiber.Ctx) error {
+		c.Set("Cache-Control", "no-store, max-age=0")
+		c.Set("Pragma", "no-cache")
+		c.Set("X-Content-Type-Options", "nosniff")
+		c.Set("X-Frame-Options", "DENY")
+		c.Set("Referrer-Policy", "no-referrer")
+		if !validTotpViewAuth(c.Get("Authorization"), password) {
+			c.Set("WWW-Authenticate", `Basic realm="FWH TOTP", charset="UTF-8"`)
+			return c.SendStatus(fiber.StatusUnauthorized)
+		}
+		return c.Next()
+	})
+
+	view.Get("/", func(c *fiber.Ctx) error {
+		page, err := totpViewFiles.ReadFile("totp_view.html")
+		if err != nil {
+			return fiber.ErrInternalServerError
+		}
+		c.Type("html", "utf-8")
+		return c.Send(page)
+	})
+
+	view.Get("/code", func(c *fiber.Ctx) error {
+		now := time.Now()
+		code, err := fwh_pro.GenerateTOTP(secret, now)
+		if err != nil {
+			return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "TOTP 配置无效"})
+		}
+		return c.JSON(fiber.Map{
+			"code":       code,
+			"expiresAt":  (now.Unix()/30 + 1) * 30000,
+			"serverTime": now.UnixMilli(),
+		})
+	})
+}
+
+func validTotpViewAuth(header, password string) bool {
+	const prefix = "Basic "
+	if !strings.HasPrefix(header, prefix) {
+		return false
+	}
+	decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(header, prefix))
+	if err != nil {
+		return false
+	}
+	username, candidate, ok := strings.Cut(string(decoded), ":")
+	return ok && subtle.ConstantTimeCompare([]byte(username), []byte("fwh")) == 1 &&
+		subtle.ConstantTimeCompare([]byte(candidate), []byte(password)) == 1
+}

+ 83 - 0
service/routes/totpViewRoutes_test.go

@@ -0,0 +1,83 @@
+package routes
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func TestTotpViewRoutes(t *testing.T) {
+	app := fiber.New()
+	TotpViewSetup(app, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", "view-password")
+
+	for _, path := range []string{"/fwh-totp/", "/fwh-totp/code"} {
+		req, _ := http.NewRequest(http.MethodGet, path, nil)
+		res, err := app.Test(req)
+		if err != nil || res.StatusCode != http.StatusUnauthorized {
+			t.Fatalf("unauthorized %s: status %v, err %v", path, res.StatusCode, err)
+		}
+		if res.Header.Get("Cache-Control") != "no-store, max-age=0" {
+			t.Fatalf("missing no-store on %s", path)
+		}
+	}
+
+	req, _ := http.NewRequest(http.MethodGet, "/fwh-totp/code", nil)
+	req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("fwh:wrong")))
+	res, err := app.Test(req)
+	if err != nil || res.StatusCode != http.StatusUnauthorized {
+		t.Fatalf("wrong password: status %v, err %v", res.StatusCode, err)
+	}
+
+	req, _ = http.NewRequest(http.MethodGet, "/fwh-totp/code", nil)
+	req.SetBasicAuth("fwh", "view-password")
+	res, err = app.Test(req)
+	if err != nil || res.StatusCode != http.StatusOK {
+		t.Fatalf("authorized request: status %v, err %v", res.StatusCode, err)
+	}
+	var body struct {
+		Code       string `json:"code"`
+		ExpiresAt  int64  `json:"expiresAt"`
+		ServerTime int64  `json:"serverTime"`
+	}
+	if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
+		t.Fatal(err)
+	}
+	if len(body.Code) != 6 || body.ExpiresAt-body.ServerTime <= 0 || body.ExpiresAt-body.ServerTime > int64(30*time.Second/time.Millisecond) {
+		t.Fatalf("invalid TOTP response: %+v", body)
+	}
+
+	for _, path := range []string{"/fwh-totp", "/fwh-totp/"} {
+		req, _ = http.NewRequest(http.MethodGet, path, nil)
+		req.SetBasicAuth("fwh", "view-password")
+		res, err = app.Test(req)
+		if err != nil || res.StatusCode != http.StatusOK || res.Header.Get("Content-Type") != "text/html; charset=utf-8" {
+			t.Fatalf("page request %s: status %v, content-type %q, err %v", path, res.StatusCode, res.Header.Get("Content-Type"), err)
+		}
+	}
+}
+
+func TestTotpViewDisabledWithoutPassword(t *testing.T) {
+	app := fiber.New()
+	TotpViewSetup(app, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", "")
+	req, _ := http.NewRequest(http.MethodGet, "/fwh-totp/code", nil)
+	res, err := app.Test(req)
+	if err != nil || res.StatusCode != http.StatusNotFound {
+		t.Fatalf("disabled route: status %v, err %v", res.StatusCode, err)
+	}
+}
+
+func TestMainRoutesDoNotExposeTotpView(t *testing.T) {
+	app := fiber.New()
+	Setup(app)
+	for _, path := range []string{"/fwh-totp/", "/fwh-totp/code"} {
+		req, _ := http.NewRequest(http.MethodGet, path, nil)
+		res, err := app.Test(req)
+		if err != nil || res.StatusCode != http.StatusNotFound {
+			t.Fatalf("main app route %s: status %v, err %v", path, res.StatusCode, err)
+		}
+	}
+}

+ 74 - 0
service/routes/totp_view.html

@@ -0,0 +1,74 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="color-scheme" content="light">
+  <title>FWH 验证码</title>
+  <style>
+    :root { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #25313b; background: #f6f8fa; }
+    body { margin: 0; min-height: 100vh; display: grid; place-items: center; }
+    main { box-sizing: border-box; width: min(360px, calc(100% - 32px)); padding: 32px; background: white; border: 1px solid #e5e9ed; border-radius: 14px; box-shadow: 0 8px 30px #1d34420a; text-align: center; }
+    h1 { margin: 0 0 24px; font-size: 18px; font-weight: 600; }
+    #code { min-height: 58px; font-size: clamp(36px, 11vw, 48px); font-weight: 650; letter-spacing: .12em; font-variant-numeric: tabular-nums; white-space: nowrap; }
+    #status { margin: 18px 0 0; color: #687783; font-size: 14px; }
+    .track { height: 4px; margin-top: 18px; background: #e9eef1; border-radius: 4px; overflow: hidden; }
+    #progress { height: 100%; width: 0; background: #568baa; transition: width .2s linear; }
+  </style>
+</head>
+<body>
+  <main>
+    <h1>FWH 验证码</h1>
+    <div id="code" aria-live="polite">··· ···</div>
+    <p id="status">正在获取验证码…</p>
+    <div class="track" aria-hidden="true"><div id="progress"></div></div>
+  </main>
+  <script>
+    const codeElement = document.getElementById('code');
+    const statusElement = document.getElementById('status');
+    const progressElement = document.getElementById('progress');
+    let expiresAt = 0;
+    let clockOffset = 0;
+    let loading = false;
+    let lastFetch = 0;
+
+    async function refreshCode() {
+      if (loading) return;
+      loading = true;
+      lastFetch = Date.now();
+      try {
+        const codePath = location.pathname.replace(/\/?$/, '/') + 'code';
+        const response = await fetch(codePath, { cache: 'no-store', credentials: 'same-origin' });
+        if (!response.ok) throw new Error('request failed');
+        const data = await response.json();
+        if (!/^\d{6}$/.test(data.code)) throw new Error('invalid code');
+        clockOffset = data.serverTime - Date.now();
+        expiresAt = data.expiresAt;
+        codeElement.textContent = data.code.slice(0, 3) + ' ' + data.code.slice(3);
+        updateCountdown();
+      } catch (_) {
+        expiresAt = 0;
+        codeElement.textContent = '··· ···';
+        statusElement.textContent = '获取失败,正在重试…';
+        progressElement.style.width = '0%';
+      } finally {
+        loading = false;
+      }
+    }
+
+    function updateCountdown() {
+      if (!expiresAt) return;
+      const remaining = Math.max(0, expiresAt - (Date.now() + clockOffset));
+      statusElement.textContent = remaining > 0 ? Math.ceil(remaining / 1000) + ' 秒后更新' : '正在更新…';
+      progressElement.style.width = (remaining / 30000 * 100) + '%';
+      if ((remaining === 0 || Date.now() - lastFetch >= 5000) && !loading) refreshCode();
+    }
+
+    refreshCode();
+    setInterval(() => {
+      if (expiresAt) updateCountdown();
+      else if (!loading && Date.now() - lastFetch >= 3000) refreshCode();
+    }, 250);
+  </script>
+</body>
+</html>