2 Commits 4cfaf25d49 ... 287dcac93d

Auteur SHA1 Message Date
  Jerry Liang 287dcac93d feat:生成账号登录添加TOTP支持 il y a 1 mois
  Jerry Liang 1285154d48 feat:添加FWH-TOTP验证码网页显示功能 il y a 1 mois

+ 8 - 4
deploy/config.env.example

@@ -49,12 +49,16 @@ FWH_ENERGY="energy.franklinwh.com"
 FWH_OSS="oss.franklinwh.com"
 # TODO: [必填] FWH 生产账号
 FWH_USER="FMU006"
-# TODO: [必填] FWH 生产账号密码(后端会自动做 MD5,此处填明文)
+# TODO: [必填] FWH 生产账号密码(此处填明文,后端提交 MD5 摘要)
 FWH_PASSWORD="CHANGE_ME"
-# 启用 TOTP 时填写认证器提供的 Base32 密钥;留空则沿用密码登录。
+# 账号需要 TOTP 时填写认证器提供的 Base32 密钥;不需要时留空。
 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 账号,仅在需要连测试服时填写

+ 22 - 0
service/README.md

@@ -96,3 +96,25 @@ Database / Cache (Redis) / 外部服务 (FWH-API)
 | POSTGRES_DSN | PGSQL数据库连接配置,DSN格式,本地保存数据信息。 |
 | MONGO_DSN | MONGO数据库连接配置,DSN格式,本地保存数据信息。 |
 | REDIS_ADDR | REDIS本地缓存服务器的域名或IP地址,默认"localhost" |
+
+## FWH 生产账号登录
+
+`FWH_PASSWORD` 在配置中填写明文,后端会计算 MD5 摘要提交。后端先调用
+`/api-user/user/login`:如果响应直接包含
+正式 `token`,即完成密码登录;如果响应声明 `mfaRequired`,则需要配置
+`FWH_TOTP_SECRET`,后端会用返回的 `mfaToken` 和当前六位验证码调用
+`/api-user/mfa/login/verify`,再保存验证响应中的正式 `token`。账号要求 MFA 但未配置
+密钥时,登录会明确失败。`FWH_TOTP_FIELD` 已不再使用。
+
+## 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
+凭据和验证码。

+ 33 - 27
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 {
@@ -29,7 +32,6 @@ type FwhApiConfig struct {
 	FwhUser       string
 	FwhPassword   string
 	FwhTotpSecret string
-	FwhTotpField  string
 }
 
 type FwhMqttConfig struct {
@@ -105,7 +107,7 @@ type Config struct {
 // var FWH_ENERGY string = "energy.franklinwh.com"
 // var FWH_OSS string = "oss.franklinwh.com"
 // var FWH_USER string = ""
-// var FWH_PASSWORD string = "" // MD5 - PASSWORD
+// var FWH_PASSWORD string = "" // The production API receives its MD5 digest.
 
 // LoadConfig 读取配置文件和环境变量。
 // 可选参数 configPaths 指定候选配置文件路径(按顺序尝试,命中第一个存在的即停)。
@@ -127,6 +129,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 +180,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"),
@@ -195,7 +202,6 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 			FwhUser:       v.GetString("FWH_USER"),
 			FwhPassword:   fwhApiPassword,
 			FwhTotpSecret: v.GetString("FWH_TOTP_SECRET"),
-			FwhTotpField:  v.GetString("FWH_TOTP_FIELD"),
 		},
 		FwhTestApi: FwhApiConfig{
 			FwhEnergy:   v.GetString("FWH_TEST_ENERGY"),

+ 28 - 0
service/config/config_test.go

@@ -0,0 +1,28 @@
+package config
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestFWHPasswordsAreMD5Encoded(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "config.env")
+	if err := os.WriteFile(path, []byte("FWH_PASSWORD=plain-password\nFWH_TEST_PASSWORD=test-password\n"), 0600); err != nil {
+		t.Fatal(err)
+	}
+	cfg, err := LoadConfig(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	productionHash := md5.Sum([]byte("plain-password"))
+	if cfg.FwhApi.FwhPassword != hex.EncodeToString(productionHash[:]) {
+		t.Fatalf("production password was not MD5 encoded")
+	}
+	testHash := md5.Sum([]byte("test-password"))
+	if cfg.FwhTestApi.FwhPassword != hex.EncodeToString(testHash[:]) {
+		t.Fatalf("test environment password hashing changed")
+	}
+}

+ 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()
 	}()
 

+ 15 - 2
service/repositories/api/fwh_pro/fwhHttpClient.go

@@ -21,7 +21,6 @@ var (
 	fwhUser       string
 	fwhPassword   string
 	fwhTotpSecret string
-	fwhTotpField  string
 	fwhHttpClient httpClient.HttpClient
 )
 
@@ -32,7 +31,6 @@ func FwhHttpInit(cfg *config.FwhApiConfig) {
 	fwhUser = cfg.FwhUser
 	fwhPassword = cfg.FwhPassword
 	fwhTotpSecret = cfg.FwhTotpSecret
-	fwhTotpField = cfg.FwhTotpField
 
 	fwhHttpClient = httpClient.HttpClient{}
 	fwhHttpClient.RegisterLogin(reLogin)
@@ -98,6 +96,21 @@ func FwhHttpPostWithoutToken(url string, body any) (*httpClient.Response, error)
 	return fwhHttpClient.HttpPost(url, body, &opt)
 }
 
+// postFwhLoginRequest uses the headers shared by both browser login steps.
+func postFwhLoginRequest(path string, body any, clientID string) (*httpClient.Response, error) {
+	t := timeout / time.Second
+	opt := httpClient.HttpClientOptions{
+		Timeout: &t,
+		Header: &http.Header{
+			"Accept":    {"application/json", "text/plain", "*/*"},
+			"Client-Id": {clientID},
+			"Lang":      {"ZH_CN"},
+			"Optsource": {"6"},
+		},
+	}
+	return fwhHttpClient.HttpPost(getFullEnergyUrl(path), body, &opt)
+}
+
 func FwhHttpPost(url string, body any) (*httpClient.Response, error) {
 	t := timeout / time.Second
 	opt := httpClient.HttpClientOptions{

+ 80 - 31
service/repositories/api/fwh_pro/fwh_login.go

@@ -3,7 +3,11 @@ package fwh_pro
 import (
 	"encoding/json"
 	"fmt"
+	"fwh-toolbox-server/utils/httpClient"
+	"strings"
 	"time"
+
+	"github.com/google/uuid"
 )
 
 var loginInfo LoginData = LoginData{
@@ -15,12 +19,26 @@ type LoginPayload struct {
 	LoginName string `json:"loginName"`
 	Password  string `json:"password"`
 	Type      int    `json:"type"`
+	Timezone  string `json:"timezone"`
 	Lang      string `json:"lang"`
 }
 
 type LoginData struct {
-	UserId uint   `json:"userId"`
-	Token  string `json:"token"`
+	UserId              uint     `json:"userId"`
+	Token               string   `json:"token"`
+	MfaRequired         bool     `json:"mfaRequired"`
+	MfaToken            string   `json:"mfaToken"`
+	MfaMethod           string   `json:"mfaMethod"`
+	AvailableMfaMethods []string `json:"availableMfaMethods"`
+}
+
+type MfaVerifyPayload struct {
+	MfaToken          string `json:"mfaToken"`
+	Code              string `json:"code"`
+	Method            string `json:"method"`
+	DeviceFingerprint string `json:"deviceFingerprint"`
+	RememberDevice    string `json:"rememberDevice"`
+	Lang              string `json:"lang"`
 }
 
 type LoginResponse struct {
@@ -44,52 +62,71 @@ type JwtPayload struct {
 }
 
 func Login() error {
+	return loginWithPost(postFwhLoginRequest, time.Now, uuid.NewString())
+}
+
+type loginPostFunc func(path string, body any, clientID string) (*httpClient.Response, error)
+
+func loginWithPost(post loginPostFunc, now func() time.Time, clientID string) error {
 	// A failed re-login must not leave a previously issued token available for reuse.
 	ClearToken()
-	// 要发送的 JSON
 	payload := LoginPayload{
 		LoginName: fwhUser,
 		Password:  fwhPassword,
 		Type:      1,
+		Timezone:  "Asia/Shanghai",
 		Lang:      "ZH_CN",
 	}
 
-	var body any = payload
-	if fwhTotpSecret != "" {
-		if fwhTotpField == "" {
-			return fmt.Errorf("FWH_TOTP_FIELD is required when FWH_TOTP_SECRET is set")
-		}
-		if fwhTotpField == "loginName" || fwhTotpField == "password" || fwhTotpField == "type" || fwhTotpField == "lang" {
-			return fmt.Errorf("FWH_TOTP_FIELD conflicts with an existing login field")
-		}
-		code, err := generateTOTP(fwhTotpSecret, time.Now())
-		if err != nil {
-			return err
-		}
-		body = map[string]any{
-			"loginName":  payload.LoginName,
-			"password":   payload.Password,
-			"type":       payload.Type,
-			"lang":       payload.Lang,
-			fwhTotpField: code,
-		}
-	}
-
-	response, err := FwhHttpPostWithoutToken(getFullOssUrl("/api-user/user/login"), body)
+	response, err := post("/api-user/user/login", payload, clientID)
 	if err != nil {
 		return err
 	}
-	// logger.Std.Printf("Status: %s\nBody: %s", resp.Status, body)
 
 	var loginRes LoginResponse
-	// 反序列化(unmarshal)
-	jsonErr := json.Unmarshal(response.Body, &loginRes)
-	if jsonErr != nil {
-		return jsonErr
+	if err := json.Unmarshal(response.Body, &loginRes); err != nil {
+		return err
 	}
-	if response.StatusCode != 200 || !loginRes.Success || loginRes.Data.Token == "" {
+	if response.StatusCode != 200 || !loginRes.Success || loginRes.Code != 200 {
 		return fmt.Errorf("FWH login rejected (HTTP %d, code %d)", response.StatusCode, loginRes.Code)
 	}
+	if loginRes.Data.MfaRequired {
+		if fwhTotpSecret == "" {
+			return fmt.Errorf("FWH login requires TOTP; set FWH_TOTP_SECRET")
+		}
+		if loginRes.Data.MfaToken == "" {
+			return fmt.Errorf("FWH login requires TOTP but returned no mfaToken")
+		}
+		if !supportsTOTP(loginRes.Data) {
+			return fmt.Errorf("FWH login requires an unsupported MFA method")
+		}
+		code, err := generateTOTP(fwhTotpSecret, now())
+		if err != nil {
+			return err
+		}
+		verify := MfaVerifyPayload{
+			MfaToken:          loginRes.Data.MfaToken,
+			Code:              code,
+			Method:            "TOTP",
+			DeviceFingerprint: clientID,
+			RememberDevice:    "",
+			Lang:              "ZH_CN",
+		}
+		response, err = post("/api-user/mfa/login/verify", verify, clientID)
+		if err != nil {
+			return err
+		}
+		loginRes = LoginResponse{}
+		if err := json.Unmarshal(response.Body, &loginRes); err != nil {
+			return err
+		}
+		if response.StatusCode != 200 || !loginRes.Success || loginRes.Code != 200 {
+			return fmt.Errorf("FWH TOTP verification rejected (HTTP %d, code %d)", response.StatusCode, loginRes.Code)
+		}
+	}
+	if loginRes.Data.Token == "" {
+		return fmt.Errorf("FWH login returned no token")
+	}
 
 	loginInfo.UserId = loginRes.Data.UserId
 	loginInfo.Token = loginRes.Data.Token
@@ -97,6 +134,18 @@ func Login() error {
 	return nil
 }
 
+func supportsTOTP(data LoginData) bool {
+	if strings.EqualFold(data.MfaMethod, "TOTP") {
+		return true
+	}
+	for _, method := range data.AvailableMfaMethods {
+		if strings.EqualFold(method, "TOTP") {
+			return true
+		}
+	}
+	return false
+}
+
 // 弃用:token payload被加密
 // func isTokenExpired(token string) (bool, error) {
 // 	parts := strings.Split(token, ".")

+ 124 - 0
service/repositories/api/fwh_pro/fwh_login_test.go

@@ -0,0 +1,124 @@
+package fwh_pro
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+	"encoding/json"
+	"fwh-toolbox-server/utils/httpClient"
+	"strings"
+	"testing"
+	"time"
+)
+
+func loginTestResponse(data any) *httpClient.Response {
+	body, _ := json.Marshal(map[string]any{"code": 200, "success": true, "data": data})
+	return &httpClient.Response{StatusCode: 200, Body: body}
+}
+
+func withLoginTestConfig(t *testing.T, secret string) {
+	t.Helper()
+	oldUser, oldPassword, oldSecret, oldInfo := fwhUser, fwhPassword, fwhTotpSecret, loginInfo
+	passwordHash := md5.Sum([]byte("plain-password"))
+	fwhUser, fwhPassword, fwhTotpSecret = "user", hex.EncodeToString(passwordHash[:]), secret
+	t.Cleanup(func() {
+		fwhUser, fwhPassword, fwhTotpSecret, loginInfo = oldUser, oldPassword, oldSecret, oldInfo
+	})
+}
+
+func TestLoginDirectWithoutTOTP(t *testing.T) {
+	withLoginTestConfig(t, "")
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		if path != "/api-user/user/login" || clientID != "device-1" {
+			t.Fatalf("unexpected request: %s, %s", path, clientID)
+		}
+		payload, ok := body.(LoginPayload)
+		if !ok || payload.Password != fwhPassword || payload.Timezone != "Asia/Shanghai" || payload.Lang != "ZH_CN" {
+			t.Fatalf("unexpected login payload: %+v", payload)
+		}
+		return loginTestResponse(map[string]any{"userId": 7, "token": "direct-token"}), nil
+	}
+	if err := loginWithPost(post, time.Now, "device-1"); err != nil {
+		t.Fatal(err)
+	}
+	if calls != 1 || loginInfo.Token != "direct-token" || loginInfo.UserId != 7 {
+		t.Fatalf("direct login result: calls=%d, token=%q, userId=%d", calls, loginInfo.Token, loginInfo.UserId)
+	}
+}
+
+func TestLoginTOTPFromMFAResponse(t *testing.T) {
+	withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		if clientID != "device-1" {
+			t.Fatalf("client ID changed between login steps")
+		}
+		switch calls {
+		case 1:
+			if path != "/api-user/user/login" {
+				t.Fatalf("unexpected first path: %s", path)
+			}
+			return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil
+		case 2:
+			verify, ok := body.(MfaVerifyPayload)
+			if path != "/api-user/mfa/login/verify" || !ok || verify.MfaToken != "temporary-token" || verify.Code != "287082" || verify.Method != "TOTP" || verify.DeviceFingerprint != clientID || verify.RememberDevice != "" {
+				t.Fatalf("unexpected verify request: path=%s, body=%+v", path, body)
+			}
+			return loginTestResponse(map[string]any{"userId": 9, "token": "verified-token"}), nil
+		default:
+			t.Fatalf("too many login requests")
+			return nil, nil
+		}
+	}
+	if err := loginWithPost(post, func() time.Time { return time.Unix(59, 0) }, "device-1"); err != nil {
+		t.Fatal(err)
+	}
+	if calls != 2 || loginInfo.Token != "verified-token" || loginInfo.UserId != 9 {
+		t.Fatalf("TOTP login result: calls=%d, token=%q, userId=%d", calls, loginInfo.Token, loginInfo.UserId)
+	}
+}
+
+func TestLoginRequiresConfiguredTOTPWhenMFARequested(t *testing.T) {
+	withLoginTestConfig(t, "")
+	loginInfo.Token = "old-token"
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil
+	}
+	err := loginWithPost(post, time.Now, "device-1")
+	if err == nil || !strings.Contains(err.Error(), "FWH_TOTP_SECRET") || calls != 1 || loginInfo.Token != "" {
+		t.Fatalf("missing TOTP handling: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token)
+	}
+}
+
+func TestLoginWithConfiguredSecretAcceptsDirectToken(t *testing.T) {
+	withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		return loginTestResponse(map[string]any{"userId": 4, "token": "direct-token"}), nil
+	}
+	if err := loginWithPost(post, time.Now, "device-1"); err != nil || calls != 1 || loginInfo.Token != "direct-token" {
+		t.Fatalf("direct response with secret: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token)
+	}
+}
+
+func TestLoginRejectedTOTPDoesNotReuseOldToken(t *testing.T) {
+	withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
+	loginInfo.Token = "old-token"
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		if calls == 1 {
+			return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil
+		}
+		return &httpClient.Response{StatusCode: 200, Body: []byte(`{"code":401,"success":false}`)}, nil
+	}
+	err := loginWithPost(post, func() time.Time { return time.Unix(59, 0) }, "device-1")
+	if err == nil || calls != 2 || loginInfo.Token != "" {
+		t.Fatalf("rejected TOTP handling: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token)
+	}
+}

+ 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>