Преглед на файлове

feat:fwh api添加TOTP支持

Jerry Liang преди 1 месец
родител
ревизия
4cfaf25d49

+ 4 - 0
deploy/config.env.example

@@ -51,6 +51,10 @@ FWH_OSS="oss.franklinwh.com"
 FWH_USER="FMU006"
 # TODO: [必填] FWH 生产账号密码(后端会自动做 MD5,此处填明文)
 FWH_PASSWORD="CHANGE_ME"
+# 启用 TOTP 时填写认证器提供的 Base32 密钥;留空则沿用密码登录。
+FWH_TOTP_SECRET=""
+# 登录接口接收验证码的 JSON 字段名;启用 TOTP 时必填。
+FWH_TOTP_FIELD=""
 
 # ---------- FWH API(测试,可选)----------
 # TODO: [可选] 测试环境 FWH 账号,仅在需要连测试服时填写

+ 4 - 0
deploy/config.mcp.env.example

@@ -37,6 +37,10 @@ FWH_OSS="oss.franklinwh.com"
 FWH_USER="FMU006"
 # 后端会自动做 MD5,此处填明文
 FWH_PASSWORD="CHANGE_ME"
+# 若 MCP 使用的 FWH 账号启用 TOTP,填写认证器提供的 Base32 密钥。
+FWH_TOTP_SECRET=""
+# 登录接口接收验证码的 JSON 字段名;启用 TOTP 时必填。
+FWH_TOTP_FIELD=""
 
 # ---------- Plane Web(反馈协作)----------
 PLANE_WEB_HOST="http://localhost:8600"

+ 20 - 16
service/config/config.go

@@ -24,10 +24,12 @@ type AppConfig struct {
 }
 
 type FwhApiConfig struct {
-	FwhEnergy   string
-	FwhOss      string
-	FwhUser     string
-	FwhPassword string
+	FwhEnergy     string
+	FwhOss        string
+	FwhUser       string
+	FwhPassword   string
+	FwhTotpSecret string
+	FwhTotpField  string
 }
 
 type FwhMqttConfig struct {
@@ -61,13 +63,13 @@ type PlaneApiConfig struct {
 }
 
 type PlaneWebApiConfig struct {
-	Host           string
-	Email          string
-	Password       string
-	WorkspaceSlug  string
-	ProjectId      string
-	LabelBugId     string
-	LabelFeatureId string
+	Host            string
+	Email           string
+	Password        string
+	WorkspaceSlug   string
+	ProjectId       string
+	LabelBugId      string
+	LabelFeatureId  string
 	LabelFeedbackId string
 }
 
@@ -89,7 +91,7 @@ type KiroConfig struct {
 type Config struct {
 	App         AppConfig
 	FwhApi      FwhApiConfig
-	FwhTestApi	FwhApiConfig
+	FwhTestApi  FwhApiConfig
 	FwhMqtt     FwhMqttConfig
 	FwhTestMqtt FwhMqttConfig
 	FwhEs       FwhEsConfig
@@ -188,10 +190,12 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 			AsynqmonPort:    v.GetString("ASYNQMON_PORT"),
 		},
 		FwhApi: FwhApiConfig{
-			FwhEnergy:   v.GetString("FWH_ENERGY"),
-			FwhOss:      v.GetString("FWH_OSS"),
-			FwhUser:     v.GetString("FWH_USER"),
-			FwhPassword: fwhApiPassword,
+			FwhEnergy:     v.GetString("FWH_ENERGY"),
+			FwhOss:        v.GetString("FWH_OSS"),
+			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"),

+ 4 - 0
service/repositories/api/fwh_pro/fwhHttpClient.go

@@ -20,6 +20,8 @@ var (
 	fwhOss        string
 	fwhUser       string
 	fwhPassword   string
+	fwhTotpSecret string
+	fwhTotpField  string
 	fwhHttpClient httpClient.HttpClient
 )
 
@@ -29,6 +31,8 @@ func FwhHttpInit(cfg *config.FwhApiConfig) {
 	fwhOss = cfg.FwhOss
 	fwhUser = cfg.FwhUser
 	fwhPassword = cfg.FwhPassword
+	fwhTotpSecret = cfg.FwhTotpSecret
+	fwhTotpField = cfg.FwhTotpField
 
 	fwhHttpClient = httpClient.HttpClient{}
 	fwhHttpClient.RegisterLogin(reLogin)

+ 29 - 1
service/repositories/api/fwh_pro/fwh_login.go

@@ -2,6 +2,8 @@ package fwh_pro
 
 import (
 	"encoding/json"
+	"fmt"
+	"time"
 )
 
 var loginInfo LoginData = LoginData{
@@ -42,6 +44,8 @@ type JwtPayload struct {
 }
 
 func Login() error {
+	// A failed re-login must not leave a previously issued token available for reuse.
+	ClearToken()
 	// 要发送的 JSON
 	payload := LoginPayload{
 		LoginName: fwhUser,
@@ -50,7 +54,28 @@ func Login() error {
 		Lang:      "ZH_CN",
 	}
 
-	response, err := FwhHttpPostWithoutToken(getFullOssUrl("/api-user/user/login"), payload)
+	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)
 	if err != nil {
 		return err
 	}
@@ -62,6 +87,9 @@ func Login() error {
 	if jsonErr != nil {
 		return jsonErr
 	}
+	if response.StatusCode != 200 || !loginRes.Success || loginRes.Data.Token == "" {
+		return fmt.Errorf("FWH login rejected (HTTP %d, code %d)", response.StatusCode, loginRes.Code)
+	}
 
 	loginInfo.UserId = loginRes.Data.UserId
 	loginInfo.Token = loginRes.Data.Token

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

@@ -0,0 +1,30 @@
+package fwh_pro
+
+import (
+	"crypto/hmac"
+	"crypto/sha1"
+	"encoding/base32"
+	"encoding/binary"
+	"fmt"
+	"strings"
+	"time"
+)
+
+// generateTOTP implements RFC 6238 with the common SHA-1, 6-digit, 30-second profile.
+func generateTOTP(secret string, now time.Time) (string, error) {
+	secret = strings.ToUpper(strings.Join(strings.Fields(secret), ""))
+	secret = strings.TrimRight(secret, "=")
+	key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
+	if err != nil || len(key) == 0 {
+		return "", fmt.Errorf("invalid FWH TOTP secret: expected a Base32 key")
+	}
+
+	var counter [8]byte
+	binary.BigEndian.PutUint64(counter[:], uint64(now.Unix()/30))
+	mac := hmac.New(sha1.New, key)
+	_, _ = mac.Write(counter[:])
+	digest := mac.Sum(nil)
+	offset := digest[len(digest)-1] & 0x0f
+	value := binary.BigEndian.Uint32(digest[offset:offset+4]) & 0x7fffffff
+	return fmt.Sprintf("%06d", value%1000000), nil
+}

+ 35 - 0
service/repositories/api/fwh_pro/totp_test.go

@@ -0,0 +1,35 @@
+package fwh_pro
+
+import (
+	"testing"
+	"time"
+)
+
+func TestGenerateTOTP(t *testing.T) {
+	// RFC 6238 Appendix B SHA-1 vectors, truncated to six digits.
+	secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
+	tests := []struct {
+		unix int64
+		want string
+	}{
+		{59, "287082"},
+		{1111111109, "081804"},
+		{1111111111, "050471"},
+		{1234567890, "005924"},
+		{2000000000, "279037"},
+	}
+	for _, tt := range tests {
+		got, err := generateTOTP(secret, time.Unix(tt.unix, 0))
+		if err != nil || got != tt.want {
+			t.Errorf("at %d: got %q, err %v; want %q", tt.unix, got, err, tt.want)
+		}
+	}
+}
+
+func TestGenerateTOTPInvalidSecret(t *testing.T) {
+	for _, secret := range []string{"", "not-base32!"} {
+		if _, err := generateTOTP(secret, time.Unix(59, 0)); err == nil {
+			t.Errorf("expected error for %q", secret)
+		}
+	}
+}